Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Image Editing addon: non-destructive image edits stored per
workpiece and applied wherever its image is used (canvas, 3D preview,
operations). The first edit is Tools > Invert Image (#515)
- Image Editing: Tools > Adjust Image... corrects the brightness,
contrast and gamma of the selected images and sharpens them with an
unsharp mask whose radius is in mm. The canvas shows the result while
editing, and Apply is a single undo step (#515)
- Addons: the `register_image_edits` hook and image edit registry
(plugin API 23) let addons provide their own image edits

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
"""The Adjust Image dialog: tone and sharpness of the selected images."""

from collections.abc import Sequence
from gettext import gettext as _

from gi.repository import Adw, GLib, Gtk

from rayforge.core.undo import HistoryManager
from rayforge.core.workpiece import WorkPiece
from rayforge.ui_gtk.shared.patched_dialog_window import (
PatchedDialogWindow,
)
from rayforge.ui_gtk.shared.pref_rows import LengthSpinRow
from rayforge.ui_gtk.shared.slider import create_slider_row

from .adjustments import ImageAdjustments
from .commands import replace_op_edits, set_op_edits

#: Delay before an edited value updates the canvas preview.
PREVIEW_DELAY_MS = 250

#: The dialog width, matching the step settings dialog.
DEFAULT_WIDTH = 600


class AdjustImageDialog(PatchedDialogWindow):
"""
Edits the "sharpen" and "tone" edits of the selected workpieces.
Changes show on the canvas right away; Apply makes them one undo
step and closing the dialog without applying restores the images.
"""

def __init__(
self,
parent: Gtk.Window | None,
history: HistoryManager,
workpieces: Sequence[WorkPiece],
):
super().__init__(transient_for=parent)
self._history = history
self._workpieces = list(workpieces)
self._originals = {wp.uid: wp.image_edits for wp in self._workpieces}
self._applied = False
self._preview_timer_id: int | None = None

self.set_title(_("Adjust Image"))
self.set_default_size(DEFAULT_WIDTH, -1)

main_view = Adw.ToolbarView()
main_view.add_top_bar(self._build_header())
scrolled = Gtk.ScrolledWindow(
hscrollbar_policy=Gtk.PolicyType.NEVER,
vscrollbar_policy=Gtk.PolicyType.AUTOMATIC,
)
page = self._build_page()
scrolled.set_child(page)
natural_height = page.measure(Gtk.Orientation.VERTICAL, DEFAULT_WIDTH)[
1
]
scrolled.set_min_content_height(natural_height)
main_view.set_content(scrolled)
self.set_content(main_view)

first = self._workpieces[0].image_edits if self._workpieces else ()
self.set_adjustments(ImageAdjustments.from_edits(first))
self.connect("close-request", self._on_close_request)

def _build_header(self) -> Adw.HeaderBar:
header = Adw.HeaderBar()
reset_btn = Gtk.Button(label=_("_Reset"), use_underline=True)
reset_btn.connect("clicked", lambda *a: self.reset())
header.pack_start(reset_btn)
apply_btn = Gtk.Button(label=_("_Apply"), use_underline=True)
apply_btn.add_css_class("suggested-action")
apply_btn.connect("clicked", lambda *a: self.apply())
header.pack_end(apply_btn)
return header

def _slider_row(
self, title, subtitle, lower, upper, step, digits, format_suffix=None
):
adjustment = Gtk.Adjustment(
value=lower,
lower=lower,
upper=upper,
step_increment=step,
page_increment=step * 10,
)
return create_slider_row(
title=title,
subtitle=subtitle,
adjustment=adjustment,
digits=digits,
on_value_changed=self._schedule_preview,
format_suffix=format_suffix,
)

def _build_page(self) -> Adw.PreferencesPage:
self.brightness_row, self.brightness_scale = self._slider_row(
_("Brightness"),
_("Lighten or darken the image"),
-100,
100,
1,
0,
format_suffix="%",
)
self.contrast_row, self.contrast_scale = self._slider_row(
_("Contrast"),
_("Spread or compress the tones around mid-gray"),
-100,
100,
1,
0,
format_suffix="%",
)
self.gamma_row, self.gamma_scale = self._slider_row(
_("Gamma"),
_("Values above 1 lighten the midtones, below 1 darken them"),
0.2,
5.0,
0.05,
2,
)
self.sharpen_row, self.sharpen_scale = self._slider_row(
_("Sharpen"),
_("Strength of the unsharp mask, in percent"),
0,
300,
5,
0,
format_suffix="%",
)
self.radius_row = LengthSpinRow(
_("Sharpen Radius"),
_("Size of the details that get sharpened"),
lower=0.01,
upper=5.0,
step_increment=0.05,
digits=3,
debounce_ms=PREVIEW_DELAY_MS,
)
self.radius_row.value_changed.connect(lambda *a: self.preview())

tone = Adw.PreferencesGroup(title=_("Tone"))
for row in (self.brightness_row, self.contrast_row, self.gamma_row):
tone.add(row)
sharpness = Adw.PreferencesGroup(title=_("Sharpness"))
sharpness.add(self.sharpen_row)
sharpness.add(self.radius_row)
page = Adw.PreferencesPage()
page.add(tone)
page.add(sharpness)
return page

def get_adjustments(self) -> ImageAdjustments:
return ImageAdjustments(
brightness=self.brightness_scale.get_value(),
contrast=self.contrast_scale.get_value(),
gamma=self.gamma_scale.get_value(),
sharpen_amount=self.sharpen_scale.get_value(),
sharpen_radius_mm=self.radius_row.get_value_in_base_units(),
)

def set_adjustments(self, values: ImageAdjustments) -> None:
self.brightness_scale.set_value(values.brightness)
self.contrast_scale.set_value(values.contrast)
self.gamma_scale.set_value(values.gamma)
self.sharpen_scale.set_value(values.sharpen_amount)
self.radius_row.set_value_in_base_units(values.sharpen_radius_mm)

def _schedule_preview(self, *args) -> None:
if self._preview_timer_id is not None:
GLib.source_remove(self._preview_timer_id)
self._preview_timer_id = GLib.timeout_add(
PREVIEW_DELAY_MS, self._flush_preview
)

def _flush_preview(self) -> bool:
self._preview_timer_id = None
self.preview()
return GLib.SOURCE_REMOVE

def preview(self) -> None:
"""Shows the current values on the canvas, without undo."""
edits = self.get_adjustments().to_edits()
for wp in self._workpieces:
wp.image_edits = replace_op_edits(self._originals[wp.uid], edits)

def reset(self) -> None:
self.set_adjustments(ImageAdjustments())
self.preview()

def apply(self) -> None:
set_op_edits(
self._history,
self._workpieces,
self.get_adjustments().to_edits(),
_("Adjust Image"),
self._originals,
)
self._applied = True
self.close()

def _on_close_request(self, *args) -> bool:
if self._preview_timer_id is not None:
GLib.source_remove(self._preview_timer_id)
self._preview_timer_id = None
if not self._applied:
for wp in self._workpieces:
wp.image_edits = self._originals[wp.uid]
return False
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
from __future__ import annotations

from collections.abc import Iterable
from dataclasses import dataclass

from rayforge.core.image_edit import ImageEditStep

from .ops import SharpenOp, ToneOp


@dataclass(frozen=True)
class ImageAdjustments:
"""
The values of the Adjust Image dialog. They map to a "sharpen" and a
"tone" edit; neutral values mean the edit is not needed.
"""

brightness: float = 0.0
contrast: float = 0.0
gamma: float = 1.0
sharpen_amount: float = 0.0
sharpen_radius_mm: float = 0.2

@property
def has_tone(self) -> bool:
return (
self.brightness != 0.0 or self.contrast != 0.0 or self.gamma != 1.0
)

@property
def has_sharpen(self) -> bool:
return self.sharpen_amount > 0.0 and self.sharpen_radius_mm > 0.0

def to_edits(self) -> dict[str, ImageEditStep | None]:
"""
The edits by op name, sharpen first so the tone curve works on
the sharpened image; ``None`` removes that op's edit.
"""
sharpen = ImageEditStep(
SharpenOp.NAME,
{
"amount": self.sharpen_amount,
"radius_mm": self.sharpen_radius_mm,
},
)
tone = ImageEditStep(
ToneOp.NAME,
{
"brightness": self.brightness,
"contrast": self.contrast,
"gamma": self.gamma,
},
)
return {
SharpenOp.NAME: sharpen if self.has_sharpen else None,
ToneOp.NAME: tone if self.has_tone else None,
}

@classmethod
def from_edits(cls, edits: Iterable[ImageEditStep]) -> ImageAdjustments:
"""Reads the values back from a workpiece's edit stack."""
defaults = cls()
values = {}
for edit in edits:
if edit.op == ToneOp.NAME:
values["brightness"] = edit.params.get("brightness", 0.0)
values["contrast"] = edit.params.get("contrast", 0.0)
values["gamma"] = edit.params.get("gamma", 1.0)
elif edit.op == SharpenOp.NAME:
values["sharpen_amount"] = edit.params.get("amount", 0.0)
values["sharpen_radius_mm"] = edit.params.get(
"radius_mm", defaults.sharpen_radius_mm
)
return cls(**values)
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from collections.abc import Sequence
from collections.abc import Mapping, Sequence

from rayforge.core.image_edit import ImageEditStep
from rayforge.core.undo import ChangePropertyCommand, HistoryManager
Expand Down Expand Up @@ -42,3 +42,58 @@ def toggle_image_edit(
)
)
return add


def replace_op_edits(
edits: tuple[ImageEditStep, ...],
replacements: Mapping[str, ImageEditStep | None],
) -> tuple[ImageEditStep, ...]:
"""
Puts each replacement where the edit of the same op was, or appends
it; ``None`` removes the op's edit. Other edits keep their order.
"""
result = []
placed = set()
for edit in edits:
if edit.op not in replacements:
result.append(edit)
continue
new = replacements[edit.op]
if new is not None and edit.op not in placed:
result.append(new)
placed.add(edit.op)
for op, new in replacements.items():
if new is not None and op not in placed:
result.append(new)
return tuple(result)


def set_op_edits(
history: HistoryManager,
workpieces: Sequence[WorkPiece],
replacements: Mapping[str, ImageEditStep | None],
name: str,
old_edits: Mapping[str, tuple[ImageEditStep, ...]] | None = None,
) -> None:
"""
Applies :func:`replace_op_edits` to every workpiece as one undo
step. ``old_edits`` maps workpiece uids to the stacks to undo to,
for callers that already showed the new edits as a live preview.
"""
old_edits = old_edits or {}
with history.transaction(name) as t:
for wp in workpieces:
old = old_edits.get(wp.uid, wp.image_edits)
new = replace_op_edits(old, replacements)
if new == old:
wp.image_edits = old
continue
t.execute(
ChangePropertyCommand(
target=wp,
property_name="image_edits",
new_value=new,
old_value=old,
name=name,
)
)
Loading
Loading