diff --git a/CHANGELOG.md b/CHANGELOG.md index d16ae179e..32a067751 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/adjust_dialog.py b/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/adjust_dialog.py new file mode 100644 index 000000000..43b13fefa --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/adjust_dialog.py @@ -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 diff --git a/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/adjustments.py b/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/adjustments.py new file mode 100644 index 000000000..b081b1ad3 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/adjustments.py @@ -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) diff --git a/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/commands.py b/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/commands.py index cc44fdc7c..444afad4e 100644 --- a/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/commands.py +++ b/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/commands.py @@ -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 @@ -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, + ) + ) diff --git a/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/frontend.py b/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/frontend.py index a1bb9a23c..fa162f1be 100644 --- a/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/frontend.py +++ b/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/frontend.py @@ -13,6 +13,7 @@ from rayforge.core.hooks import hookimpl from rayforge.ui_gtk.action_registry import MenuPlacement +from .adjust_dialog import AdjustImageDialog from .commands import toggle_image_edit from .ops import INVERT @@ -21,6 +22,7 @@ ADDON_NAME = "image_editing" INVERT_ACTION = "invert-image" +ADJUST_ACTION = "adjust-image" def _has_selection(action_manager: "ActionManager") -> bool: @@ -30,6 +32,11 @@ def _has_selection(action_manager: "ActionManager") -> bool: @hookimpl def register_actions(action_registry): """Register actions with menu placement.""" + _register_invert(action_registry) + _register_adjust(action_registry) + + +def _register_invert(action_registry): action = Gio.SimpleAction.new(INVERT_ACTION, None) def on_activate(action, param): @@ -50,3 +57,26 @@ def on_activate(action, param): menu=MenuPlacement(menu_id="tools", priority=60), enabled_when=_has_selection, ) + + +def _register_adjust(action_registry): + action = Gio.SimpleAction.new(ADJUST_ACTION, None) + + def on_activate(action, param): + window = action_registry.window + dialog = AdjustImageDialog( + window, + window.doc_editor.history_manager, + window.surface.get_selected_workpieces(), + ) + dialog.present() + + action.connect("activate", on_activate) + action_registry.register( + action_name=ADJUST_ACTION, + action=action, + addon_name=ADDON_NAME, + label=_("Adjust Image..."), + menu=MenuPlacement(menu_id="tools", priority=61), + enabled_when=_has_selection, + ) diff --git a/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/ops.py b/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/ops.py index 92dbaaace..97eac4c74 100644 --- a/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/ops.py +++ b/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/ops.py @@ -2,10 +2,33 @@ from gettext import gettext as _ from typing import Any +import numpy as np import pyvips from rayforge.core.image_edit import ImageEditOp, ImageEditStep +#: Smallest blur sigma, in pixels, that still changes the image. +MIN_SIGMA_PX = 0.1 + + +def _max_value(image: pyvips.Image) -> int: + return 65535 if image.format == "ushort" else 255 + + +def _split_alpha( + image: pyvips.Image, +) -> tuple[pyvips.Image, pyvips.Image | None]: + if not image.hasalpha(): + return image, None + color = image.extract_band(0, n=image.bands - 1) + return color, image.extract_band(image.bands - 1) + + +def _join_alpha( + color: pyvips.Image, alpha: pyvips.Image | None +) -> pyvips.Image: + return color if alpha is None else color.bandjoin(alpha) + class InvertOp(ImageEditOp): """Inverts the colour of the image and leaves its alpha alone.""" @@ -24,6 +47,104 @@ def apply( return color.invert().bandjoin(alpha) +def tone_curve( + brightness: float, contrast: float, gamma: float, max_value: int = 255 +) -> np.ndarray: + """ + The tone curve as a lookup table with ``max_value + 1`` entries: + brightness, then contrast around mid-gray, then gamma. Brightness + and contrast run from -100 to 100; a gamma above 1 lightens the + midtones. + """ + values = np.linspace(0.0, 1.0, max_value + 1) + values = values + brightness / 100.0 + factor = max(0.0, 1.0 + contrast / 100.0) + values = np.clip((values - 0.5) * factor + 0.5, 0.0, 1.0) + values = values ** (1.0 / max(gamma, 1e-3)) + dtype = np.uint16 if max_value > 255 else np.uint8 + return np.rint(values * max_value).astype(dtype) + + +class ToneOp(ImageEditOp): + """Brightness, contrast and gamma of the colour; alpha is kept.""" + + NAME = "tone" + LABEL = _("Brightness and Contrast") + + @classmethod + def apply( + cls, image: pyvips.Image, params: Mapping[str, Any] + ) -> pyvips.Image: + color, alpha = _split_alpha(image) + if color.format not in ("uchar", "ushort"): + color = color.cast("uchar") + lut = tone_curve( + params.get("brightness", 0.0), + params.get("contrast", 0.0), + params.get("gamma", 1.0), + _max_value(color), + ) + table = pyvips.Image.new_from_array(lut.reshape(1, -1)) + return _join_alpha(color.maplut(table), alpha) + + +def _blur(image: pyvips.Image, sigma_x: float, sigma_y: float) -> pyvips.Image: + if sigma_x >= MIN_SIGMA_PX: + mask = pyvips.Image.gaussmat( + sigma_x, 0.01, separable=True, precision="float" + ) + image = image.conv(mask, precision="float") + if sigma_y >= MIN_SIGMA_PX: + mask = pyvips.Image.gaussmat( + sigma_y, 0.01, separable=True, precision="float" + ) + image = image.conv(mask.rot90(), precision="float") + return image + + +def _blurred_color( + image: pyvips.Image, sigma_x: float, sigma_y: float +) -> pyvips.Image: + """ + The blurred colour bands. With alpha, the blur runs on premultiplied + colour, so transparent pixels do not darken the edges. + """ + if not image.hasalpha(): + return _blur(image.cast("float"), sigma_x, sigma_y) + max_alpha = _max_value(image) + premultiplied = image.premultiply(max_alpha=max_alpha) + blurred = _blur(premultiplied, sigma_x, sigma_y) + color = blurred.unpremultiply(max_alpha=max_alpha) + return color.extract_band(0, n=image.bands - 1) + + +class SharpenOp(ImageEditOp): + """ + Unsharp mask: ``amount`` is the strength in percent and + ``radius_mm`` the blur radius (Gaussian sigma) in millimetres, + turned into pixels with the image's resolution. + """ + + NAME = "sharpen" + LABEL = _("Sharpen") + + @classmethod + def apply( + cls, image: pyvips.Image, params: Mapping[str, Any] + ) -> pyvips.Image: + amount = params.get("amount", 0.0) / 100.0 + radius = params.get("radius_mm", 0.0) + sigma_x, sigma_y = radius * image.xres, radius * image.yres + if amount <= 0.0 or max(sigma_x, sigma_y) < MIN_SIGMA_PX: + return image + color, alpha = _split_alpha(image) + values = color.cast("float") + blurred = _blurred_color(image, sigma_x, sigma_y) + sharpened = values + (values - blurred) * amount + result = sharpened.rint().cast(color.format) + return _join_alpha(result, alpha) + + INVERT = ImageEditStep(InvertOp.NAME) -ALL_OPS = (InvertOp,) +ALL_OPS = (InvertOp, ToneOp, SharpenOp) diff --git a/rayforge/builtin_addons/rayforge-addon-image-editing/tests/conftest.py b/rayforge/builtin_addons/rayforge-addon-image-editing/tests/conftest.py index 610cc9fd8..b21a0455f 100644 --- a/rayforge/builtin_addons/rayforge-addon-image-editing/tests/conftest.py +++ b/rayforge/builtin_addons/rayforge-addon-image-editing/tests/conftest.py @@ -3,7 +3,7 @@ """ import pytest -from image_editing.ops import InvertOp +from image_editing.ops import ALL_OPS from rayforge.core.image_edit import image_edit_registry @@ -13,6 +13,7 @@ @pytest.fixture(scope="session", autouse=True) def register_image_edits(): """Registers the addon's image edit ops for all tests.""" - image_edit_registry.register(InvertOp, addon_name=ADDON_NAME) + for op_class in ALL_OPS: + image_edit_registry.register(op_class, addon_name=ADDON_NAME) yield image_edit_registry.unregister_all_from_addon(ADDON_NAME) diff --git a/rayforge/builtin_addons/rayforge-addon-image-editing/tests/test_adjustments.py b/rayforge/builtin_addons/rayforge-addon-image-editing/tests/test_adjustments.py new file mode 100644 index 000000000..778a703b7 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-image-editing/tests/test_adjustments.py @@ -0,0 +1,45 @@ +from image_editing.adjustments import ImageAdjustments + +from rayforge.core.image_edit import ImageEditStep + + +def test_defaults_need_no_edits(): + assert ImageAdjustments().to_edits() == {"sharpen": None, "tone": None} + + +def test_tone_values_become_a_tone_edit(): + edits = ImageAdjustments(brightness=10, gamma=1.5).to_edits() + assert edits["sharpen"] is None + assert edits["tone"] == ImageEditStep( + "tone", {"brightness": 10, "contrast": 0.0, "gamma": 1.5} + ) + + +def test_sharpen_needs_amount_and_radius(): + assert ImageAdjustments(sharpen_amount=50).to_edits()["sharpen"] == ( + ImageEditStep("sharpen", {"amount": 50, "radius_mm": 0.2}) + ) + no_radius = ImageAdjustments(sharpen_amount=50, sharpen_radius_mm=0) + assert no_radius.to_edits()["sharpen"] is None + + +def test_sharpen_comes_before_tone(): + edits = ImageAdjustments(contrast=5, sharpen_amount=50).to_edits() + assert list(edits) == ["sharpen", "tone"] + + +def test_round_trip_through_the_edit_stack(): + values = ImageAdjustments( + brightness=-5, + contrast=20, + gamma=0.8, + sharpen_amount=120, + sharpen_radius_mm=0.3, + ) + edits = [e for e in values.to_edits().values() if e is not None] + stack = [ImageEditStep("invert"), *edits] + assert ImageAdjustments.from_edits(stack) == values + + +def test_empty_stack_reads_as_defaults(): + assert ImageAdjustments.from_edits([]) == ImageAdjustments() diff --git a/rayforge/builtin_addons/rayforge-addon-image-editing/tests/test_commands.py b/rayforge/builtin_addons/rayforge-addon-image-editing/tests/test_commands.py index 2a975e189..82f6a6281 100644 --- a/rayforge/builtin_addons/rayforge-addon-image-editing/tests/test_commands.py +++ b/rayforge/builtin_addons/rayforge-addon-image-editing/tests/test_commands.py @@ -1,4 +1,8 @@ -from image_editing.commands import toggle_image_edit +from image_editing.commands import ( + replace_op_edits, + set_op_edits, + toggle_image_edit, +) from image_editing.ops import INVERT from rayforge.core.image_edit import ImageEditStep @@ -49,3 +53,60 @@ def test_empty_selection_does_nothing(): history = HistoryManager() assert not toggle_image_edit(history, [], INVERT, "Invert") assert not history.can_undo() + + +TONE = ImageEditStep("tone", {"brightness": 10}) +NEW_TONE = ImageEditStep("tone", {"brightness": 20}) +SHARPEN = ImageEditStep("sharpen", {"amount": 50, "radius_mm": 0.2}) + + +def test_replace_keeps_the_position_of_the_op(): + edits = (TONE, INVERT) + assert replace_op_edits(edits, {"tone": NEW_TONE}) == (NEW_TONE, INVERT) + + +def test_replace_appends_missing_ops_in_order(): + result = replace_op_edits((INVERT,), {"sharpen": SHARPEN, "tone": TONE}) + assert result == (INVERT, SHARPEN, TONE) + + +def test_replace_with_none_removes_the_op(): + edits = (TONE, INVERT, SHARPEN) + result = replace_op_edits(edits, {"tone": None, "sharpen": None}) + assert result == (INVERT,) + + +def test_replace_drops_duplicate_edits_of_the_op(): + edits = (TONE, INVERT, TONE) + assert replace_op_edits(edits, {"tone": NEW_TONE}) == (NEW_TONE, INVERT) + + +def test_set_op_edits_is_one_undo_step(): + history = HistoryManager() + a, b = WorkPiece("a"), WorkPiece("b") + b.image_edits = [INVERT] + set_op_edits(history, [a, b], {"tone": TONE}, "Adjust") + assert a.image_edits == (TONE,) + assert b.image_edits == (INVERT, TONE) + history.undo() + assert a.image_edits == () + assert b.image_edits == (INVERT,) + + +def test_set_op_edits_undoes_to_the_stack_before_the_preview(): + history = HistoryManager() + wp = WorkPiece("a") + wp.image_edits = [NEW_TONE] + set_op_edits(history, [wp], {"tone": TONE}, "Adjust", {wp.uid: ()}) + assert wp.image_edits == (TONE,) + history.undo() + assert wp.image_edits == () + + +def test_set_op_edits_without_change_restores_the_preview(): + history = HistoryManager() + wp = WorkPiece("a") + wp.image_edits = [NEW_TONE] + set_op_edits(history, [wp], {"tone": None}, "Adjust", {wp.uid: ()}) + assert wp.image_edits == () + assert not history.can_undo() diff --git a/rayforge/builtin_addons/rayforge-addon-image-editing/tests/test_ops.py b/rayforge/builtin_addons/rayforge-addon-image-editing/tests/test_ops.py index 7851b6296..64fa84c6a 100644 --- a/rayforge/builtin_addons/rayforge-addon-image-editing/tests/test_ops.py +++ b/rayforge/builtin_addons/rayforge-addon-image-editing/tests/test_ops.py @@ -1,6 +1,12 @@ import numpy as np import pyvips -from image_editing.ops import INVERT, InvertOp +from image_editing.ops import ( + INVERT, + InvertOp, + SharpenOp, + ToneOp, + tone_curve, +) from rayforge.core.image_edit import image_edit_registry @@ -31,3 +37,124 @@ def test_invert_without_alpha(): def test_invert_grayscale_with_alpha(): gray_alpha = _image(40, 0).copy(interpretation="b-w") assert _pixel(InvertOp.apply(gray_alpha, {})) == [215, 0] + + +def _ramp(width: int = 256, height: int = 4, alpha: int | None = None): + row = np.arange(width, dtype=np.uint8) + pixels = np.tile(row, (height, 1)) + if alpha is None: + return pyvips.Image.new_from_array(pixels).copy(interpretation="b-w") + bands = np.dstack([pixels, np.full_like(pixels, alpha)]) + return pyvips.Image.new_from_array(bands).copy(interpretation="b-w") + + +def _gray(*values: int) -> pyvips.Image: + pixels = np.array([values], dtype=np.uint8) + return pyvips.Image.new_from_array(pixels).copy(interpretation="b-w") + + +def _row(image: pyvips.Image) -> list[int]: + return image.numpy().reshape(image.height, -1)[0].tolist() + + +def _tone(image: pyvips.Image, **params) -> pyvips.Image: + return ToneOp.apply(image, params) + + +def test_tone_and_sharpen_are_registered(): + assert image_edit_registry.get("tone") is ToneOp + assert image_edit_registry.get("sharpen") is SharpenOp + + +def test_tone_curve_is_identity_by_default(): + np.testing.assert_array_equal(tone_curve(0, 0, 1.0), np.arange(256)) + assert tone_curve(0, 0, 1.0, 65535).dtype == np.uint16 + + +def test_neutral_tone_keeps_the_image(): + image = _ramp() + assert _row(_tone(image)) == _row(image) + + +def test_brightness_shifts_and_clips(): + assert _row(_tone(_gray(100), brightness=20)) == [151] + assert _row(_tone(_gray(100), brightness=-20)) == [49] + assert set(_row(_tone(_ramp(), brightness=100))) == {255} + assert set(_row(_tone(_ramp(), brightness=-100))) == {0} + + +def test_contrast_scales_around_mid_gray(): + image = _gray(64, 128, 192) + assert _row(_tone(image, contrast=50)) == [32, 128, 224] + assert _row(_tone(image, contrast=-50)) == [96, 128, 160] + assert set(_row(_tone(_ramp(), contrast=-100))) == {128} + + +def test_gamma_above_one_lightens_midtones(): + image = _gray(0, 128, 255) + lighter = _row(_tone(image, gamma=2.0)) + darker = _row(_tone(image, gamma=0.5)) + assert lighter == [0, round(255 * (128 / 255) ** 0.5), 255] + assert darker == [0, round(255 * (128 / 255) ** 2), 255] + + +def test_tone_curve_is_monotonic(): + curve = tone_curve(10, 30, 1.4).astype(int) + assert (np.diff(curve) >= 0).all() + + +def test_tone_keeps_alpha_and_colour_format(): + rgba = _image(100, 100, 100, 7).copy(interpretation="srgb") + result = _tone(rgba, brightness=20) + assert _pixel(result) == [151, 151, 151, 7] + assert result.format == "uchar" + + +def test_tone_handles_16_bit_images(): + pixels = np.array([[0, 32768, 65535]], dtype=np.uint16) + image = pyvips.Image.new_from_array(pixels).copy(interpretation="grey16") + result = _tone(image, contrast=-100) + assert result.format == "ushort" + assert set(_row(result)) == {32768} + + +def _edge(ppm: float = 10.0) -> pyvips.Image: + pixels = np.zeros((20, 40), dtype=np.uint8) + pixels[:, :20] = 60 + pixels[:, 20:] = 180 + image = pyvips.Image.new_from_array(pixels).copy(interpretation="b-w") + return image.copy(xres=ppm, yres=ppm) + + +def _sharpen(image: pyvips.Image, **params) -> pyvips.Image: + return SharpenOp.apply(image, params) + + +def test_sharpen_without_amount_or_radius_is_neutral(): + image = _edge() + assert _sharpen(image, amount=0, radius_mm=0.2) is image + assert _sharpen(image, amount=100, radius_mm=0.0) is image + + +def test_sharpen_increases_edge_contrast(): + result = _row(_sharpen(_edge(), amount=100, radius_mm=0.2)) + assert result[19] < 60 + assert result[20] > 180 + assert result[0] == 60 and result[-1] == 180 + + +def test_sharpen_radius_is_in_mm(): + coarse = _row(_sharpen(_edge(ppm=10.0), amount=100, radius_mm=0.2)) + fine = _row(_sharpen(_edge(ppm=5.0), amount=100, radius_mm=0.4)) + assert coarse == fine + + +def test_sharpen_ignores_transparent_neighbours(): + pixels = np.zeros((10, 20, 2), dtype=np.uint8) + pixels[:, 10:] = (120, 255) + image = pyvips.Image.new_from_array(pixels).copy( + interpretation="b-w", xres=10.0, yres=10.0 + ) + result = _sharpen(image, amount=200, radius_mm=0.2).numpy() + assert set(result[:, 10:, 0].ravel()) == {120} + assert set(result[..., 1].ravel()) == {0, 255} diff --git a/rayforge/builtin_addons/rayforge-addon-image-editing/tests/test_raster_equivalence.py b/rayforge/builtin_addons/rayforge-addon-image-editing/tests/test_raster_equivalence.py index ce1914470..8df1702a4 100644 --- a/rayforge/builtin_addons/rayforge-addon-image-editing/tests/test_raster_equivalence.py +++ b/rayforge/builtin_addons/rayforge-addon-image-editing/tests/test_raster_equivalence.py @@ -1,11 +1,12 @@ """ The Invert edit replaces the Engrave step's old Invert setting; these -tests pin the behaviour that setting had. +tests pin the behaviour that setting had, and that the other edits reach +the Engrave step the same way. """ import numpy as np import pyvips -from image_editing.ops import InvertOp +from image_editing.ops import InvertOp, ToneOp from rayforge.image.dither import DitherAlgorithm from rayforge.image.util import normalize_to_rgba, vips_rgba_to_cairo_surface @@ -50,3 +51,18 @@ def test_inverted_power_modulation_flips_visible_pixels_only(): assert gray is not None and alpha is not None assert np.all(gray[:, :5] == 0) assert np.all(alpha[:, 5:] == 0) + + +def test_tone_edit_reaches_power_modulation(): + pixels = np.zeros((10, 10, 4), dtype=np.uint8) + pixels[:, :5] = (128, 128, 128, 255) + image = pyvips.Image.new_from_array(pixels).copy(interpretation="srgb") + darker = ToneOp.apply(image, {"gamma": 0.5}) + gray, alpha = preprocess_raster_image( + _surface(darker), + mode=DepthMode.POWER_MODULATION, + auto_levels=False, + ) + assert gray is not None and alpha is not None + assert np.all(gray[:, :5] == round(255 * (128 / 255) ** 2)) + assert np.all(alpha[:, 5:] == 0) diff --git a/rayforge/builtin_addons/rayforge-addon-image-editing/tests/ui_gtk/conftest.py b/rayforge/builtin_addons/rayforge-addon-image-editing/tests/ui_gtk/conftest.py new file mode 100644 index 000000000..0906c6f6c --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-image-editing/tests/ui_gtk/conftest.py @@ -0,0 +1,29 @@ +"""UI fixtures for image_editing dialog tests.""" + +import asyncio + +import pytest + +from rayforge import config as config_module +from rayforge import context as context_module +from rayforge.context import get_context + + +@pytest.fixture +def ui_context(monkeypatch, tmp_path): + """An isolated application context, as the unit rows need one.""" + config_dir = tmp_path / "config" + monkeypatch.setattr(config_module, "CONFIG_DIR", config_dir) + monkeypatch.setattr(config_module, "DIALECT_DIR", config_dir / "dialects") + monkeypatch.setattr(config_module, "MACHINE_DIR", config_dir / "machines") + monkeypatch.setattr(config_module, "ADDONS_DIR", config_dir / "addons") + monkeypatch.setattr( + config_module, "CONFIG_FILE", config_dir / "config.yaml" + ) + monkeypatch.setattr( + config_module, "AI_CONFIG_FILE", config_dir / "ai.yaml" + ) + context = get_context() + yield context + asyncio.run(context.shutdown()) + context_module._context_instance = None diff --git a/rayforge/builtin_addons/rayforge-addon-image-editing/tests/ui_gtk/test_adjust_dialog.py b/rayforge/builtin_addons/rayforge-addon-image-editing/tests/ui_gtk/test_adjust_dialog.py new file mode 100644 index 000000000..46a5fc0af --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-image-editing/tests/ui_gtk/test_adjust_dialog.py @@ -0,0 +1,112 @@ +# flake8: noqa: E402 +"""UI tests for the Adjust Image dialog.""" + +import gi +import pytest +import pyvips + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") + +from image_editing.adjust_dialog import AdjustImageDialog +from image_editing.adjustments import ImageAdjustments +from image_editing.ops import INVERT + +from rayforge.core.image_edit import ImageEditStep +from rayforge.core.undo import HistoryManager +from rayforge.core.workpiece import WorkPiece + +TONE = ImageEditStep( + "tone", {"brightness": 10.0, "contrast": 0.0, "gamma": 1.0} +) + + +@pytest.fixture +def workpieces(): + a, b = WorkPiece("a"), WorkPiece("b") + a.image_edits = [TONE] + b.image_edits = [INVERT] + return [a, b] + + +@pytest.fixture +def dialog(ui_context, workpieces): + dlg = AdjustImageDialog(None, HistoryManager(), workpieces) + dlg.present() + yield dlg + dlg.destroy() + + +@pytest.mark.ui +def test_opens_with_the_first_workpiece_values(dialog): + assert dialog.get_adjustments() == ImageAdjustments(brightness=10) + + +@pytest.mark.ui +def test_is_not_modal(dialog): + assert not dialog.get_modal() + + +@pytest.mark.ui +def test_preview_shows_without_undo(dialog, workpieces): + dialog.contrast_scale.set_value(20) + dialog.preview() + a, b = workpieces + assert a.image_edits[0].params["contrast"] == 20 + assert b.image_edits[0] == INVERT + assert b.image_edits[1].params["contrast"] == 20 + assert not dialog._history.can_undo() + + +@pytest.mark.ui +def test_closing_without_apply_restores_the_edits(dialog, workpieces): + dialog.sharpen_scale.set_value(100) + dialog.preview() + dialog.close() + assert workpieces[0].image_edits == (TONE,) + assert workpieces[1].image_edits == (INVERT,) + + +@pytest.mark.ui +def test_apply_is_one_undo_step(dialog, workpieces): + history = dialog._history + dialog.gamma_scale.set_value(1.5) + dialog.preview() + dialog.apply() + assert workpieces[0].image_edits[0].params["gamma"] == 1.5 + assert workpieces[1].image_edits[1].params["gamma"] == 1.5 + history.undo() + assert workpieces[0].image_edits == (TONE,) + assert workpieces[1].image_edits == (INVERT,) + + +@pytest.mark.ui +def test_reset_removes_the_adjustments(dialog, workpieces): + dialog.reset() + dialog.apply() + assert workpieces[0].image_edits == () + assert workpieces[1].image_edits == (INVERT,) + + +def _cache_a_render(wp: WorkPiece) -> None: + wp._processed_cache.put((10, 10, wp.size), pyvips.Image.black(10, 10)) + + +@pytest.mark.ui +def test_previews_do_not_accumulate_cached_images(dialog, workpieces): + for value in range(10): + for wp in workpieces: + _cache_a_render(wp) + dialog.brightness_scale.set_value(value) + dialog.preview() + assert all(len(wp._processed_cache) == 0 for wp in workpieces) + + +@pytest.mark.ui +def test_closing_drops_the_preview_images(dialog, workpieces): + dialog.sharpen_scale.set_value(100) + dialog.preview() + for wp in workpieces: + _cache_a_render(wp) + dialog.close() + assert all(len(wp._processed_cache) == 0 for wp in workpieces) diff --git a/rayforge/core/image_cache.py b/rayforge/core/image_cache.py new file mode 100644 index 000000000..c872a0a1d --- /dev/null +++ b/rayforge/core/image_cache.py @@ -0,0 +1,43 @@ +""" +Retention policy of the in-memory image caches of workpieces and +source assets: each cache keeps at most a few images and a byte budget, +evicting the least recently used image first. The newest image is +always kept, even when it alone exceeds the budget. +""" + +import pyvips + +from ..shared.util.cache import LruCache + +IMAGE_CACHE_MAX_ENTRIES = 4 +IMAGE_CACHE_MAX_BYTES = 128 * 1024 * 1024 + +_VIPS_FORMAT_BYTES = { + "uchar": 1, + "char": 1, + "ushort": 2, + "short": 2, + "uint": 4, + "int": 4, + "float": 4, + "complex": 8, + "double": 8, + "dpcomplex": 16, +} + + +def image_nbytes(image: pyvips.Image) -> int: + """The uncompressed size of the image's pixels in bytes.""" + sample = _VIPS_FORMAT_BYTES.get(image.format, 8) + return image.width * image.height * image.bands * sample + + +def new_image_cache( + max_entries: int = IMAGE_CACHE_MAX_ENTRIES, +) -> LruCache: + """An LRU cache of images with the shared byte budget.""" + return LruCache( + max_entries=max_entries, + max_bytes=IMAGE_CACHE_MAX_BYTES, + sizeof=image_nbytes, + ) diff --git a/rayforge/core/image_edit/base.py b/rayforge/core/image_edit/base.py index 00bf9d4fe..2b5b5ffe6 100644 --- a/rayforge/core/image_edit/base.py +++ b/rayforge/core/image_edit/base.py @@ -57,4 +57,8 @@ class ImageEditOp(ABC): def apply( cls, image: pyvips.Image, params: Mapping[str, Any] ) -> pyvips.Image: - """Returns the edited image; ``image`` is left untouched.""" + """ + Returns the edited image; ``image`` is left untouched. Its + ``xres``/``yres`` give the resolution in pixels per mm, for + edits with physical sizes. + """ diff --git a/rayforge/core/source_asset.py b/rayforge/core/source_asset.py index 8f5b6f1c7..515c035df 100644 --- a/rayforge/core/source_asset.py +++ b/rayforge/core/source_asset.py @@ -4,7 +4,6 @@ import hashlib import logging import uuid -from collections import OrderedDict from dataclasses import dataclass, field from gettext import gettext as _ from pathlib import Path @@ -13,7 +12,9 @@ import pyvips from blinker import Signal +from ..shared.util.cache import LruCache from .asset import IAsset +from .image_cache import new_image_cache logger = logging.getLogger(__name__) @@ -58,8 +59,12 @@ class SourceAsset(IAsset): _hidden: bool = field(init=False, default=False) _updated: Signal = field(init=False, default_factory=Signal) extra: dict[str, Any] = field(default_factory=dict) - _base_image_cache: OrderedDict = field( - init=False, default_factory=OrderedDict, repr=False + _base_image_cache: LruCache = field( + init=False, + default_factory=lambda: new_image_cache( + SourceAsset._BASE_IMAGE_CACHE_MAX_SIZE + ), + repr=False, ) _BASE_IMAGE_CACHE_MAX_SIZE: ClassVar[int] = 10 @@ -77,12 +82,8 @@ def cache_base_image( self, data_id: int, width: int, height: int, image: pyvips.Image ) -> None: key = (data_id, width, height) - if key in self._base_image_cache: - self._base_image_cache.move_to_end(key) - else: - self._base_image_cache[key] = image - while len(self._base_image_cache) > self._BASE_IMAGE_CACHE_MAX_SIZE: - self._base_image_cache.popitem(last=False) + if self._base_image_cache.get(key) is None: + self._base_image_cache.put(key, image) def clear_base_image_cache(self) -> None: self._base_image_cache.clear() diff --git a/rayforge/core/workpiece.py b/rayforge/core/workpiece.py index e91e162fc..9fc954308 100644 --- a/rayforge/core/workpiece.py +++ b/rayforge/core/workpiece.py @@ -26,8 +26,10 @@ from raygeo.ops.part import Part from ..context import get_context +from ..shared.util.cache import LruCache from .asset_registry import asset_type_registry from .geometry_provider import IGeometryProvider +from .image_cache import new_image_cache from .image_edit import ImageEditStep, apply_image_edits from .item import DocItem from .source_asset_segment import SourceAssetSegment @@ -47,6 +49,22 @@ CAIRO_MAX_DIMENSION = 16384 +def _with_resolution( + image: pyvips.Image, size_mm: tuple[float, float] +) -> pyvips.Image: + """ + Tags the image with its resolution in pixels per mm, so edits with + physical sizes (like a sharpen radius) do not depend on the render + size. + """ + width_mm, height_mm = size_mm + if width_mm <= 0 or height_mm <= 0: + return image + return image.copy( + xres=image.width / width_mm, yres=image.height / height_mm + ) + + class RenderContext(NamedTuple): """Encapsulates the resources required for rendering.""" @@ -88,12 +106,16 @@ def __init__( self._edited_boundaries: Geometry | None = None # The cache for rendered vips images. Key is (width, height). - self._render_cache: dict[tuple[int, int], pyvips.Image] = {} + self._render_cache: LruCache[tuple[int, int], pyvips.Image] = ( + new_image_cache() + ) # The non-destructive image edit stack and the cache of rendered - # images with that stack applied. Key is (width, height). + # images with that stack applied. Key is (width, height, size). self._image_edits: tuple[ImageEditStep, ...] = () - self._processed_cache: dict[tuple[int, int], pyvips.Image] = {} + self._processed_cache: LruCache[ + tuple[int, int, tuple[float, float]], pyvips.Image + ] = new_image_cache() # Transient attributes for deserialized instances in subprocesses self._data: bytes | None = None @@ -846,11 +868,14 @@ def get_processed_image( image = self._get_vips_image(width, height) if image is None or not self._image_edits: return image - key = (width, height) + size = self.size + key = (width, height, size) processed = self._processed_cache.get(key) if processed is None: - processed = apply_image_edits(image, self._image_edits) - self._processed_cache[key] = processed + processed = apply_image_edits( + _with_resolution(image, size), self._image_edits + ) + self._processed_cache.put(key, processed) return processed def _get_vips_image(self, width: int, height: int) -> pyvips.Image | None: @@ -859,8 +884,9 @@ def _get_vips_image(self, width: int, height: int) -> pyvips.Image | None: Orchestrates data retrieval, rendering, cropping, and masking. """ key = (width, height) - if key in self._render_cache: - return self._render_cache[key] + cached = self._render_cache.get(key) + if cached is not None: + return cached # 1. Resolve Context ctx = self._resolve_render_context() @@ -902,7 +928,7 @@ def _get_vips_image(self, width: int, height: int) -> pyvips.Image | None: ) if final_image: - self._render_cache[key] = final_image + self._render_cache.put(key, final_image) return final_image diff --git a/rayforge/shared/util/cache.py b/rayforge/shared/util/cache.py index 458bd8337..93f51a046 100644 --- a/rayforge/shared/util/cache.py +++ b/rayforge/shared/util/cache.py @@ -1,6 +1,11 @@ -from collections.abc import Callable +import threading +from collections import OrderedDict +from collections.abc import Callable, Hashable, Iterator from functools import lru_cache, wraps -from typing import Any +from typing import Any, Generic, TypeVar + +K = TypeVar("K", bound=Hashable) +V = TypeVar("V") def lru_cache_unless_forced(maxsize: int = 128): @@ -27,3 +32,72 @@ def wrapper(*args, **kwargs) -> Any: return wrapper return decorator + + +class LruCache(Generic[K, V]): + """ + A thread-safe mapping that keeps at most ``max_entries`` values and, + when ``max_bytes`` is set, at most that many bytes as measured by + ``sizeof``. The least recently used values are evicted first; the + newest value is always kept, even when it alone exceeds the budget. + """ + + def __init__( + self, + max_entries: int, + max_bytes: int | None = None, + sizeof: Callable[[V], int] | None = None, + ): + self.max_entries = max(1, max_entries) + self.max_bytes = max_bytes + self._sizeof = sizeof or (lambda value: 0) + self._entries: OrderedDict[K, tuple[V, int]] = OrderedDict() + self._nbytes = 0 + self._lock = threading.Lock() + + def __len__(self) -> int: + return len(self._entries) + + def __contains__(self, key: object) -> bool: + return key in self._entries + + def keys(self) -> Iterator[K]: + return iter(list(self._entries)) + + @property + def nbytes(self) -> int: + """The total size of the cached values.""" + return self._nbytes + + def get(self, key: K) -> V | None: + with self._lock: + entry = self._entries.get(key) + if entry is None: + return None + self._entries.move_to_end(key) + return entry[0] + + def put(self, key: K, value: V) -> None: + size = self._sizeof(value) + with self._lock: + old = self._entries.pop(key, None) + if old is not None: + self._nbytes -= old[1] + self._entries[key] = (value, size) + self._nbytes += size + self._evict() + + def clear(self) -> None: + with self._lock: + self._entries.clear() + self._nbytes = 0 + + def _over_budget(self) -> bool: + if len(self._entries) > self.max_entries: + return True + return self.max_bytes is not None and self._nbytes > self.max_bytes + + def _evict(self) -> None: + while len(self._entries) > 1 and self._over_budget(): + _, (_, size) = self._entries.popitem(last=False) + self._nbytes -= size diff --git a/scripts/screenshot/adjust_image_dialog.py b/scripts/screenshot/adjust_image_dialog.py new file mode 100644 index 000000000..e48f01b2f --- /dev/null +++ b/scripts/screenshot/adjust_image_dialog.py @@ -0,0 +1,121 @@ +""" +Screenshot: The Adjust Image dialog. + +Usage: pixi run screenshot addon:image-editing:adjust +""" + +import logging +import os +import sys +import tempfile +import time +from pathlib import Path + +from PIL import Image +from utils import ( + get_target, + restore_config, + run_on_main_thread, + set_window_size, + take_screenshot, + wait_for_settled, +) + +from rayforge.uiscript import app, win + +logger = logging.getLogger(__name__) + +TARGET = get_target("addon:image-editing:adjust") + +# Addons are loaded by path at runtime, not importable by name; the +# tests' root conftest puts their directories on sys.path likewise. +ADDON_DIR = ( + Path(__file__).parent.parent.parent + / "rayforge" + / "builtin_addons" + / "rayforge-addon-image-editing" +) +sys.path.insert(0, str(ADDON_DIR)) + +EXAMPLE_IMAGE = ( + Path(__file__).parent.parent.parent + / "website" + / "static" + / "images" + / "image-edit-invert-before.webp" +) + + +def _import_example_image() -> Path: + """Import the docs' example photo as a PNG, and return its path. + + The import reads the file from a background thread, so the path + stays alive until the document has settled; the caller removes it. + """ + fd, png_path = tempfile.mkstemp(prefix="rayforge-shot-", suffix=".png") + os.close(fd) + Image.open(EXAMPLE_IMAGE).save(png_path, format="PNG") + win.doc_editor.file.load_file_from_path(Path(png_path), "image/png", None) + return Path(png_path) + + +def _select_all_items(): + """Select all content items on the active layer.""" + win.surface.update_from_doc() + layer = win.doc_editor.doc.active_layer + items = layer.get_content_items() + if items: + win.surface.select_items(items) + return items + + +def _stage_values(dialog): + """Set the sliders to the values shown in the documentation.""" + dialog.brightness_scale.set_value(10) + dialog.contrast_scale.set_value(25) + dialog.sharpen_scale.set_value(50) + + +@restore_config +def main(): + set_window_size(win, 1400, 1000) + + png_path = run_on_main_thread(_import_example_image) + if not wait_for_settled(win, timeout=10): + logger.error("Document did not settle in time") + app.quit_idle() + return + png_path.unlink(missing_ok=True) + + items = run_on_main_thread(_select_all_items) + if not items: + logger.error("No items found in document") + app.quit_idle() + return + logger.info(f"Selected {len(items)} items") + + from image_editing.adjust_dialog import AdjustImageDialog + + def open_dialog(): + dialog = AdjustImageDialog( + win, + win.doc_editor.history_manager, + win.surface.get_selected_workpieces(), + ) + dialog.present() + return dialog + + dialog = run_on_main_thread(open_dialog) + time.sleep(0.5) + + run_on_main_thread(lambda: _stage_values(dialog)) + time.sleep(1.0) + + logger.info("Taking screenshot: %s", TARGET) + take_screenshot() + + run_on_main_thread(dialog.close) + app.quit_idle() + + +main() diff --git a/scripts/screenshot/cli.py b/scripts/screenshot/cli.py index 025ffbe68..5a494784b 100755 --- a/scripts/screenshot/cli.py +++ b/scripts/screenshot/cli.py @@ -22,6 +22,7 @@ TARGETS = { "addon:ai-workpiece-generator": "ai_workpiece_generator", "addon:deepnest": "deepnest", + "addon:image-editing:adjust": "adjust_image_dialog", "addon:print-and-cut:pick": "print_and_cut", "addon:print-and-cut:jog": "print_and_cut", "addon:print-and-cut:apply": "print_and_cut", diff --git a/tests/core/test_workpiece_image_edits.py b/tests/core/test_workpiece_image_edits.py index 58aeaa472..f9789cafc 100644 --- a/tests/core/test_workpiece_image_edits.py +++ b/tests/core/test_workpiece_image_edits.py @@ -1,11 +1,16 @@ from collections.abc import Mapping from pathlib import Path -from typing import Any, cast +from typing import Any, ClassVar, cast import pytest import pyvips from rayforge.core.doc import Doc +from rayforge.core.image_cache import ( + IMAGE_CACHE_MAX_BYTES, + IMAGE_CACHE_MAX_ENTRIES, + image_nbytes, +) from rayforge.core.image_edit import ( ImageEditOp, ImageEditStep, @@ -136,3 +141,98 @@ def test_old_documents_load_without_edits(workpiece): def test_in_world_copies_the_edits(workpiece): workpiece.image_edits = [ImageEditStep("test_blank")] assert workpiece.in_world().image_edits == workpiece.image_edits + + +class _ResolutionOp(ImageEditOp): + NAME = "test_resolution" + LABEL = "Resolution" + seen: ClassVar[list[tuple[float, float]]] = [] + + @classmethod + def apply( + cls, image: pyvips.Image, params: Mapping[str, Any] + ) -> pyvips.Image: + cls.seen.append((image.xres, image.yres)) + return image + + +@pytest.fixture +def resolution_op(): + image_edit_registry.register(_ResolutionOp, addon_name="test_wp_res") + _ResolutionOp.seen = [] + yield _ResolutionOp + image_edit_registry.unregister_all_from_addon("test_wp_res") + + +def test_edits_see_the_resolution_in_pixels_per_mm(workpiece, resolution_op): + workpiece.set_size(10.0, 5.0) + workpiece.image_edits = [ImageEditStep(resolution_op.NAME)] + workpiece.get_processed_image(40, 10) + assert resolution_op.seen == [pytest.approx((4.0, 2.0))] + + +def test_resizing_reapplies_the_edits(workpiece, resolution_op): + workpiece.image_edits = [ImageEditStep(resolution_op.NAME)] + workpiece.set_size(10.0, 10.0) + workpiece.get_processed_image(20, 20) + workpiece.set_size(20.0, 20.0) + workpiece.get_processed_image(20, 20) + assert resolution_op.seen == [ + pytest.approx((2.0, 2.0)), + pytest.approx((1.0, 1.0)), + ] + + +def test_slider_changes_keep_one_processed_image(workpiece, blank_op): + for value in range(20): + workpiece.image_edits = [ImageEditStep(blank_op.NAME, {"v": value})] + workpiece.get_processed_image(20, 20) + assert len(workpiece._processed_cache) == 1 + + +def test_zooming_keeps_a_bounded_number_of_images(workpiece, blank_op): + workpiece.image_edits = [ImageEditStep(blank_op.NAME)] + for zoom in range(1, 11): + workpiece.get_processed_image(10 * zoom, 10 * zoom) + assert len(workpiece._render_cache) == IMAGE_CACHE_MAX_ENTRIES + assert len(workpiece._processed_cache) == IMAGE_CACHE_MAX_ENTRIES + assert (100, 100) in workpiece._render_cache + assert (10, 10) not in workpiece._render_cache + + +def test_resizing_keeps_a_bounded_number_of_images(workpiece, blank_op): + workpiece.image_edits = [ImageEditStep(blank_op.NAME)] + for step in range(1, 11): + workpiece.set_size(10.0 * step, 10.0) + workpiece.get_processed_image(20, 20) + assert len(workpiece._processed_cache) <= IMAGE_CACHE_MAX_ENTRIES + + +def test_image_caches_have_a_byte_budget(workpiece): + for cache in (workpiece._render_cache, workpiece._processed_cache): + assert cache.max_bytes == IMAGE_CACHE_MAX_BYTES + + +def test_byte_budget_evicts_the_least_recently_used(workpiece): + workpiece._render_cache.max_bytes = 3600 + workpiece._get_vips_image(20, 20) + workpiece._get_vips_image(10, 10) + workpiece._get_vips_image(20, 20) + workpiece._get_vips_image(22, 22) + assert set(workpiece._render_cache.keys()) == {(20, 20), (22, 22)} + assert workpiece._render_cache.nbytes <= 3600 + + +def test_cached_size_is_the_uncompressed_pixel_size(): + image = pyvips.Image.black(30, 20, bands=4).cast("ushort") + assert image_nbytes(image) == 30 * 20 * 4 * 2 + + +def test_source_base_images_have_a_byte_budget(workpiece): + source = workpiece.source + assert source is not None + assert source._base_image_cache.max_bytes == IMAGE_CACHE_MAX_BYTES + big = pyvips.Image.black(4000, 3000, bands=4) + for step in range(10): + source.cache_base_image(step, 4000, 3000, big) + assert source._base_image_cache.nbytes <= IMAGE_CACHE_MAX_BYTES diff --git a/tests/shared/test_shared_util_cache.py b/tests/shared/test_shared_util_cache.py index 681ae3337..dc905745c 100644 --- a/tests/shared/test_shared_util_cache.py +++ b/tests/shared/test_shared_util_cache.py @@ -1,4 +1,4 @@ -from rayforge.shared.util.cache import lru_cache_unless_forced +from rayforge.shared.util.cache import LruCache, lru_cache_unless_forced class TestLruCacheUnlessForced: @@ -110,3 +110,58 @@ def my_function(x): return x assert my_function.__name__ == "my_function" + + +class TestLruCache: + def test_get_returns_stored_values(self): + cache = LruCache(max_entries=2) + cache.put("a", 1) + assert cache.get("a") == 1 + assert cache.get("missing") is None + assert "a" in cache + + def test_evicts_least_recently_used_entry(self): + cache = LruCache(max_entries=2) + cache.put("a", 1) + cache.put("b", 2) + cache.get("a") + cache.put("c", 3) + assert set(cache.keys()) == {"a", "c"} + + def test_byte_budget_evicts_least_recently_used(self): + cache = LruCache[str, int]( + max_entries=10, max_bytes=100, sizeof=lambda v: v + ) + cache.put("a", 40) + cache.put("b", 40) + cache.get("a") + cache.put("c", 40) + assert set(cache.keys()) == {"a", "c"} + assert cache.nbytes == 80 + + def test_keeps_newest_entry_even_over_budget(self): + cache = LruCache[str, int]( + max_entries=10, max_bytes=100, sizeof=lambda v: v + ) + cache.put("a", 40) + cache.put("big", 500) + assert list(cache.keys()) == ["big"] + assert cache.nbytes == 500 + + def test_replacing_a_key_updates_the_size(self): + cache = LruCache[str, int]( + max_entries=10, max_bytes=100, sizeof=lambda v: v + ) + cache.put("a", 40) + cache.put("a", 10) + assert len(cache) == 1 + assert cache.nbytes == 10 + + def test_clear_empties_the_cache(self): + cache = LruCache[str, int]( + max_entries=10, max_bytes=100, sizeof=lambda v: v + ) + cache.put("a", 40) + cache.clear() + assert len(cache) == 0 + assert cache.nbytes == 0 diff --git a/website/docs/addons/image-editing.md b/website/docs/addons/image-editing.md index 586fd267c..30a2aa3a4 100644 --- a/website/docs/addons/image-editing.md +++ b/website/docs/addons/image-editing.md @@ -28,6 +28,28 @@ inverted yet get inverted, so the whole selection ends up the same. Use it for lithophanes and embossing in an Engrave operation, where light areas should be engraved deep, or to engrave light artwork on a dark background. +## Adjust Image + +Select one or more workpieces and choose **Tools - Adjust Image...** to correct a photo before +engraving or tracing it: + +- **Brightness** lightens or darkens the image. +- **Contrast** spreads the tones away from mid-gray, or pulls them towards it. +- **Gamma** above 1 lightens the midtones, below 1 darkens them; black and white stay as they are. +- **Sharpen** strengthens edges and fine details (an unsharp mask). **Sharpen Radius** sets the size + of the details it works on, in millimeters, so the result is the same at any line interval or + canvas zoom. + +![The Adjust Image dialog with the tone and sharpness sliders](/screenshots/addon-image-editing-adjust.webp) + +The canvas shows the changes while you edit. **Apply** keeps them as a single undo step; closing the +dialog without applying discards them, and **Reset** sets every value back to neutral. Transparent +areas stay transparent. + +The dialog opens with the values of the first selected workpiece, and Apply gives every selected +workpiece the same values. Sharpening runs before the tone corrections, and other edits such as +Invert Image keep their place. + ## Older Projects Before this addon, Invert was a setting of the Engrave operation. When you open an older project diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/addons/image-editing.md b/website/i18n/de/docusaurus-plugin-content-docs/current/addons/image-editing.md index 019933c98..4a099e8e6 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/addons/image-editing.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/addons/image-editing.md @@ -25,6 +25,29 @@ ist. Verwende es für Lithophanen und Prägen in einer Gravur-Operation, bei der helle Bereiche tief graviert werden sollen, oder um helle Motive auf dunklem Hintergrund zu gravieren. +## Bild anpassen + +Wähle ein oder mehrere Werkstücke aus und wähle **Tools - Adjust Image...**, um ein Foto vor dem +Gravieren oder Nachzeichnen zu korrigieren: + +- **Brightness** (Helligkeit) hellt das Bild auf oder dunkelt es ab. +- **Contrast** (Kontrast) zieht die Tonwerte vom mittleren Grau weg oder zu ihm hin. +- **Gamma** über 1 hellt die Mitteltöne auf, unter 1 dunkelt es sie ab; Schwarz und Weiß bleiben, + wie sie sind. +- **Sharpen** (Schärfen) verstärkt Kanten und feine Details (eine Unscharfmaskierung). **Sharpen + Radius** legt die Größe der Details in Millimetern fest, sodass das Ergebnis bei jedem + Zeilenabstand und jeder Zoomstufe gleich ist. + +![Der Dialog „Bild anpassen“ mit den Reglern für Tonwert und Schärfe](/screenshots/addon-image-editing-adjust.webp) + +Die Leinwand zeigt die Änderungen schon während der Bearbeitung. **Apply** übernimmt sie als einen +einzigen Rückgängig-Schritt; wenn du den Dialog ohne Übernehmen schließt, werden sie verworfen, und +**Reset** setzt alle Werte auf neutral zurück. Transparente Bereiche bleiben transparent. + +Der Dialog öffnet sich mit den Werten des ersten ausgewählten Werkstücks, und Apply gibt jedem +ausgewählten Werkstück dieselben Werte. Das Schärfen erfolgt vor den Tonwertkorrekturen, und andere +Bearbeitungen wie Invert Image behalten ihren Platz. + ## Ältere Projekte Vor diesem Addon war Invertieren eine Einstellung der Gravur-Operation. Wenn du ein älteres Projekt diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/addons/image-editing.md b/website/i18n/es/docusaurus-plugin-content-docs/current/addons/image-editing.md index ad8ac619e..26f6d88f2 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/addons/image-editing.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/addons/image-editing.md @@ -23,6 +23,29 @@ invertidas se invierten, de modo que toda la selección acaba igual. Úsalo para litofanías y repujado en una operación de grabado, donde las áreas claras deben grabarse en profundidad, o para grabar dibujos claros sobre un fondo oscuro. +## Ajustar imagen + +Selecciona una o más piezas de trabajo y elige **Tools - Adjust Image...** para corregir una foto +antes de grabarla o vectorizarla: + +- **Brightness** (brillo) aclara u oscurece la imagen. +- **Contrast** (contraste) aleja los tonos del gris medio o los acerca a él. +- **Gamma** por encima de 1 aclara los tonos medios y por debajo de 1 los oscurece; el negro y el + blanco no cambian. +- **Sharpen** (enfocar) refuerza los bordes y los detalles finos (una máscara de enfoque). **Sharpen + Radius** fija el tamaño de esos detalles en milímetros, de modo que el resultado es el mismo con + cualquier intervalo de línea o nivel de zoom. + +![El diálogo de Ajustar imagen con los controles deslizantes de tono y nitidez](/screenshots/addon-image-editing-adjust.webp) + +El lienzo muestra los cambios mientras editas. **Apply** los conserva como un único paso de +deshacer; si cierras el diálogo sin aplicar, se descartan, y **Reset** devuelve todos los valores a +neutro. Las zonas transparentes siguen siendo transparentes. + +El diálogo se abre con los valores de la primera pieza seleccionada, y Apply da los mismos valores a +todas las piezas seleccionadas. El enfoque se aplica antes de las correcciones de tono, y otras +ediciones como Invert Image mantienen su lugar. + ## Proyectos antiguos Antes de este complemento, Invertir era un ajuste de la operación de grabado. Cuando abres un diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/addons/image-editing.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/addons/image-editing.md index ebeac0132..d963fc60d 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/addons/image-editing.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/addons/image-editing.md @@ -23,6 +23,29 @@ pas encore inversées le sont, pour que toute la sélection finisse identique. Utilisez-le pour les lithophanes et le gaufrage dans une opération de gravure, lorsque les zones claires doivent être gravées en profondeur, ou pour graver un motif clair sur un fond sombre. +## Ajuster l'image + +Sélectionnez une ou plusieurs pièces et choisissez **Tools - Adjust Image...** pour corriger une +photo avant de la graver ou de la vectoriser : + +- **Brightness** (luminosité) éclaircit ou assombrit l'image. +- **Contrast** (contraste) écarte les tons du gris moyen ou les en rapproche. +- **Gamma** au-dessus de 1 éclaircit les tons moyens, en dessous de 1 les assombrit ; le noir et le + blanc ne changent pas. +- **Sharpen** (netteté) renforce les contours et les détails fins (un masque flou). **Sharpen + Radius** fixe la taille de ces détails en millimètres, si bien que le résultat est le même quel + que soit l'intervalle de ligne ou le zoom. + +![La boîte de dialogue Ajuster l'image avec les curseurs de tonalité et de netteté](/screenshots/addon-image-editing-adjust.webp) + +Le canevas affiche les modifications pendant l'édition. **Apply** les conserve en une seule étape +d'annulation ; fermer la boîte de dialogue sans appliquer les abandonne, et **Reset** remet toutes +les valeurs au neutre. Les zones transparentes restent transparentes. + +La boîte de dialogue s'ouvre avec les valeurs de la première pièce sélectionnée, et Apply donne les +mêmes valeurs à toutes les pièces sélectionnées. La netteté est appliquée avant les corrections de +tons, et les autres modifications comme Invert Image gardent leur place. + ## Projets plus anciens Avant cette extension, Inverser était un réglage de l'opération de gravure. Lorsque vous ouvrez un diff --git a/website/i18n/hi/docusaurus-plugin-content-docs/current/addons/image-editing.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/addons/image-editing.md index 2371a635f..cc303c573 100644 --- a/website/i18n/hi/docusaurus-plugin-content-docs/current/addons/image-editing.md +++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/addons/image-editing.md @@ -23,6 +23,28 @@ इसे उत्कीर्णन ऑपरेशन में लिथोफ़ेन और उभार के लिए उपयोग करें, जहाँ उजले क्षेत्र गहरे उकेरे जाने चाहिए, या गहरी पृष्ठभूमि पर उजली आकृति उकेरने के लिए. +## छवि समायोजित करें + +एक या अधिक वर्कपीस चुनें और किसी फ़ोटो को एनग्रेव या ट्रेस करने से पहले ठीक करने के लिए **Tools - +Adjust Image...** चुनें: + +- **Brightness** (चमक) छवि को हल्का या गहरा करता है। +- **Contrast** (कंट्रास्ट) टोन को मध्य-ग्रे से दूर फैलाता है या उसकी ओर खींचता है। +- **Gamma** 1 से ऊपर मध्य-टोन को हल्का करता है, 1 से नीचे उन्हें गहरा करता है; काला और सफ़ेद जैसे + हैं वैसे ही रहते हैं। +- **Sharpen** (शार्पन) किनारों और बारीक विवरणों को मज़बूत करता है (एक अनशार्प मास्क)। **Sharpen + Radius** उन विवरणों का आकार मिलीमीटर में तय करता है, इसलिए परिणाम किसी भी लाइन अंतराल या ज़ूम पर + एक जैसा रहता है। + +![टोन और शार्पनेस स्लाइडर के साथ छवि समायोजित करें डायलॉग](/screenshots/addon-image-editing-adjust.webp) + +संपादन करते समय कैनवास बदलाव दिखाता है। **Apply** उन्हें एक ही अनडू चरण के रूप में रखता है; बिना +लागू किए डायलॉग बंद करने पर वे हट जाते हैं, और **Reset** हर मान को फिर से तटस्थ कर देता है। पारदर्शी +क्षेत्र पारदर्शी ही रहते हैं। + +डायलॉग पहले चुने गए वर्कपीस के मानों के साथ खुलता है, और Apply हर चुने गए वर्कपीस को वही मान देता +है। शार्पनिंग टोन सुधारों से पहले होती है, और Invert Image जैसे अन्य संपादन अपनी जगह पर रहते हैं। + ## पुराने प्रोजेक्ट इस ऐड-ऑन से पहले, उलटें उत्कीर्णन ऑपरेशन की एक सेटिंग थी. जब आप कोई पुराना प्रोजेक्ट खोलते हैं जिसने diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/addons/image-editing.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/addons/image-editing.md index 98382cf0c..2959c38b4 100644 --- a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/addons/image-editing.md +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/addons/image-editing.md @@ -23,6 +23,29 @@ invertidas são invertidas, de modo que toda a seleção termine igual. Use para litofanias e embossing em uma operação de gravação, onde áreas claras devem ser gravadas em profundidade, ou para gravar arte clara em um fundo escuro. +## Ajustar imagem + +Selecione uma ou mais peças e escolha **Tools - Adjust Image...** para corrigir uma foto antes de +gravá-la ou vetorizá-la: + +- **Brightness** (brilho) clareia ou escurece a imagem. +- **Contrast** (contraste) afasta os tons do cinza médio ou os aproxima dele. +- **Gamma** acima de 1 clareia os tons médios e abaixo de 1 os escurece; o preto e o branco não + mudam. +- **Sharpen** (nitidez) reforça bordas e detalhes finos (uma máscara de nitidez). **Sharpen Radius** + define o tamanho desses detalhes em milímetros, então o resultado é o mesmo em qualquer intervalo + de linha ou zoom. + +![A caixa de diálogo Ajustar imagem com os controles deslizantes de tom e nitidez](/screenshots/addon-image-editing-adjust.webp) + +A tela mostra as alterações enquanto você edita. **Apply** as mantém como uma única etapa de +desfazer; fechar a caixa de diálogo sem aplicar as descarta, e **Reset** volta todos os valores ao +neutro. As áreas transparentes continuam transparentes. + +A caixa de diálogo abre com os valores da primeira peça selecionada, e Apply dá os mesmos valores a +todas as peças selecionadas. A nitidez é aplicada antes das correções de tom, e outras edições como +Invert Image mantêm seu lugar. + ## Projetos mais antigos Antes deste addon, Inverter era uma configuração da operação de gravação. Quando você abre um diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/addons/image-editing.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/addons/image-editing.md index 5edefeebc..500b9aafc 100644 --- a/website/i18n/uk/docusaurus-plugin-content-docs/current/addons/image-editing.md +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/addons/image-editing.md @@ -23,6 +23,28 @@ Використовуйте це для літофаній і тиснення в операції гравіювання, де світлі області мають гравіюватися глибоко, або для гравіювання світлого зображення на темному тлі. +## Коригування зображення + +Виберіть одну або кілька заготовок і виберіть **Tools - Adjust Image...**, щоб виправити фото перед +гравіюванням або трасуванням: + +- **Brightness** (яскравість) освітлює або затемнює зображення. +- **Contrast** (контраст) розводить тони від середньо-сірого або зводить їх до нього. +- **Gamma** понад 1 освітлює середні тони, менше 1 затемнює їх; чорний і білий не змінюються. +- **Sharpen** (різкість) підсилює краї та дрібні деталі (нерізке маскування). **Sharpen Radius** + задає розмір цих деталей у міліметрах, тож результат однаковий за будь-якого інтервалу ліній чи + масштабу. + +![Діалогове вікно коригування зображення з повзунками тону та різкості](/screenshots/addon-image-editing-adjust.webp) + +Полотно показує зміни під час редагування. **Apply** зберігає їх як один крок скасування; якщо +закрити діалог без застосування, вони відкидаються, а **Reset** повертає всі значення до +нейтральних. Прозорі ділянки залишаються прозорими. + +Діалог відкривається зі значеннями першої вибраної заготовки, а Apply надає всім вибраним заготовкам +однакові значення. Різкість застосовується перед корекцією тонів, а інші редагування, як-от Invert +Image, залишаються на своєму місці. + ## Старіші проєкти До цього розширення інверсія була налаштуванням операції гравіювання. Коли ви відкриваєте старіший diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/addons/image-editing.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/addons/image-editing.md index 0b9423fac..cc8397770 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/addons/image-editing.md +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/addons/image-editing.md @@ -17,6 +17,24 @@ Image**。浅色区域变深,深色区域变浅;透明区域保持透明, 在雕刻操作中将其用于石版画和浮雕(浅色区域应雕刻得较深),或者在深色背景上雕刻浅色图案。 +## 调整图像 + +选择一个或多个工件,然后选择 **Tools - Adjust Image...**,在雕刻或描摹之前校正照片: + +- **Brightness**(亮度)使图像变亮或变暗。 +- **Contrast**(对比度)使色调远离或靠近中灰。 +- **Gamma** 大于 1 时提亮中间调,小于 1 时压暗中间调;黑色和白色保持不变。 +- **Sharpen**(锐化)增强边缘和细节(USM 锐化)。**Sharpen Radius** + 以毫米为单位设置这些细节的大小,因此无论行间距或缩放如何,结果都相同。 + +![带有色调和锐化滑块的调整图像对话框](/screenshots/addon-image-editing-adjust.webp) + +编辑时画布会实时显示更改。**Apply** 将其作为一个撤销步骤保留;不应用就关闭对话框会放弃更改, +**Reset** 将所有值恢复为中性。透明区域保持透明。 + +对话框打开时显示第一个所选工件的值,Apply 会为所有所选工件设置相同的值。锐化在色调校正之前进行,Invert +Image 等其他编辑保持原位。 + ## 旧项目 在此插件出现之前,反转是雕刻操作的一个设置。当您打开使用过该设置的旧项目时,Rayforge 会将该设置移动到该图层的工件上,因此输出保持不变。 diff --git a/website/static/screenshots/addon-image-editing-adjust.webp b/website/static/screenshots/addon-image-editing-adjust.webp new file mode 100644 index 000000000..44d899796 Binary files /dev/null and b/website/static/screenshots/addon-image-editing-adjust.webp differ