From 4418bc52c6f3db5124fcb92f7966cf4939f5b87a Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Tue, 13 Oct 2026 08:17:52 +0200 Subject: [PATCH 1/7] feat(image-edit): give edits the resolution in pixels per mm get_processed_image() tags the render with xres/yres from the workpiece size before running the edit stack, so an edit with a physical size (such as a sharpen radius in mm) gives the same result on the canvas and in the pipeline. The processed cache key includes the size, so resizing reapplies the edits. Refs #515 --- rayforge/core/image_edit/base.py | 6 +++- rayforge/core/workpiece.py | 29 +++++++++++++--- tests/core/test_workpiece_image_edits.py | 42 +++++++++++++++++++++++- 3 files changed, 71 insertions(+), 6 deletions(-) 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/workpiece.py b/rayforge/core/workpiece.py index e91e162fc..c443d0e5f 100644 --- a/rayforge/core/workpiece.py +++ b/rayforge/core/workpiece.py @@ -47,6 +47,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.""" @@ -91,9 +107,11 @@ def __init__( self._render_cache: dict[tuple[int, int], pyvips.Image] = {} # 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: dict[ + tuple[int, int, tuple[float, float]], pyvips.Image + ] = {} # Transient attributes for deserialized instances in subprocesses self._data: bytes | None = None @@ -846,10 +864,13 @@ 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) + processed = apply_image_edits( + _with_resolution(image, size), self._image_edits + ) self._processed_cache[key] = processed return processed diff --git a/tests/core/test_workpiece_image_edits.py b/tests/core/test_workpiece_image_edits.py index 58aeaa472..0df99d213 100644 --- a/tests/core/test_workpiece_image_edits.py +++ b/tests/core/test_workpiece_image_edits.py @@ -1,6 +1,6 @@ from collections.abc import Mapping from pathlib import Path -from typing import Any, cast +from typing import Any, ClassVar, cast import pytest import pyvips @@ -136,3 +136,43 @@ 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)), + ] From 0aa3b64f113df548e0ba2a13a9d1e9d793c4ec0b Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Tue, 13 Oct 2026 08:17:53 +0200 Subject: [PATCH 2/7] feat(image-edit): add tone and sharpen edits with an Adjust Image dialog Brightness, contrast and gamma become the "tone" edit (one lookup table) and the unsharp mask the "sharpen" edit, with its radius in mm. Both run on the workpiece's image via the image edit registry, in pyvips, so the Engrave step and the pipeline need no knowledge of them. Sharpening blurs premultiplied colour, so transparent areas do not darken the edges. Tools > Adjust Image... edits both for the selected workpieces with a live canvas preview; Apply is one undo step and closing discards. Refs #515 --- .../image_editing/adjust_dialog.py | 170 ++++++++++++++++++ .../image_editing/adjustments.py | 74 ++++++++ .../image_editing/commands.py | 57 +++++- .../image_editing/frontend.py | 30 ++++ .../image_editing/ops.py | 123 ++++++++++++- .../tests/conftest.py | 5 +- .../tests/test_adjustments.py | 45 +++++ .../tests/test_commands.py | 63 ++++++- .../tests/test_ops.py | 129 ++++++++++++- .../tests/test_raster_equivalence.py | 20 ++- .../tests/ui_gtk/conftest.py | 29 +++ .../tests/ui_gtk/test_adjust_dialog.py | 82 +++++++++ 12 files changed, 819 insertions(+), 8 deletions(-) create mode 100644 rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/adjust_dialog.py create mode 100644 rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/adjustments.py create mode 100644 rayforge/builtin_addons/rayforge-addon-image-editing/tests/test_adjustments.py create mode 100644 rayforge/builtin_addons/rayforge-addon-image-editing/tests/ui_gtk/conftest.py create mode 100644 rayforge/builtin_addons/rayforge-addon-image-editing/tests/ui_gtk/test_adjust_dialog.py 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..eb4e13d73 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/adjust_dialog.py @@ -0,0 +1,170 @@ +"""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, 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, SpinRow + +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 + + +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.set_title(_("Adjust Image")) + self.set_default_size(440, -1) + self.set_modal(True) + + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + box.append(self._build_header()) + box.append(self._build_page()) + self.set_content(box) + + 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 _spin_row(self, title, subtitle, lower, upper, step, digits): + row = SpinRow( + title, + subtitle, + lower=lower, + upper=upper, + step_increment=step, + digits=digits, + numeric=True, + debounce_ms=PREVIEW_DELAY_MS, + ) + row.value_changed.connect(lambda *a: self.preview()) + return row + + def _build_page(self) -> Adw.PreferencesPage: + self.brightness_row = self._spin_row( + _("Brightness"), _("Lighten or darken the image"), -100, 100, 1, 0 + ) + self.contrast_row = self._spin_row( + _("Contrast"), + _("Spread or compress the tones around mid-gray"), + -100, + 100, + 1, + 0, + ) + self.gamma_row = self._spin_row( + _("Gamma"), + _("Values above 1 lighten the midtones, below 1 darken them"), + 0.2, + 5.0, + 0.05, + 2, + ) + self.sharpen_row = self._spin_row( + _("Sharpen"), + _("Strength of the unsharp mask, in percent"), + 0, + 300, + 5, + 0, + ) + 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_row.get_value(), + contrast=self.contrast_row.get_value(), + gamma=self.gamma_row.get_value(), + sharpen_amount=self.sharpen_row.get_value(), + sharpen_radius_mm=self.radius_row.get_value_in_base_units(), + ) + + def set_adjustments(self, values: ImageAdjustments) -> None: + self.brightness_row.set_value(values.brightness) + self.contrast_row.set_value(values.contrast) + self.gamma_row.set_value(values.gamma) + self.sharpen_row.set_value(values.sharpen_amount) + self.radius_row.set_value_in_base_units(values.sharpen_radius_mm) + + 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 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..56e9cb98b --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-image-editing/tests/ui_gtk/test_adjust_dialog.py @@ -0,0 +1,82 @@ +# flake8: noqa: E402 +"""UI tests for the Adjust Image dialog.""" + +import gi +import pytest + +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_preview_shows_without_undo(dialog, workpieces): + dialog.contrast_row.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_row.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_row.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,) From 60a6b0dd77d349b8656e5be6b881270c914640b3 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Tue, 13 Oct 2026 08:17:53 +0200 Subject: [PATCH 3/7] docs: document the Adjust Image edit Refs #515 --- CHANGELOG.md | 4 ++++ website/docs/addons/image-editing.md | 20 ++++++++++++++++++ .../current/addons/image-editing.md | 21 +++++++++++++++++++ .../current/addons/image-editing.md | 21 +++++++++++++++++++ .../current/addons/image-editing.md | 21 +++++++++++++++++++ .../current/addons/image-editing.md | 20 ++++++++++++++++++ .../current/addons/image-editing.md | 21 +++++++++++++++++++ .../current/addons/image-editing.md | 20 ++++++++++++++++++ .../current/addons/image-editing.md | 16 ++++++++++++++ 9 files changed, 164 insertions(+) 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/website/docs/addons/image-editing.md b/website/docs/addons/image-editing.md index 586fd267c..cf9353ef9 100644 --- a/website/docs/addons/image-editing.md +++ b/website/docs/addons/image-editing.md @@ -28,6 +28,26 @@ 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 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..2a2515e94 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,27 @@ 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. + +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..5bfe515f0 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,27 @@ 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 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..c2b8d00e1 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,27 @@ 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. + +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..bcca3faed 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,26 @@ इसे उत्कीर्णन ऑपरेशन में लिथोफ़ेन और उभार के लिए उपयोग करें, जहाँ उजले क्षेत्र गहरे उकेरे जाने चाहिए, या गहरी पृष्ठभूमि पर उजली आकृति उकेरने के लिए. +## छवि समायोजित करें + +एक या अधिक वर्कपीस चुनें और किसी फ़ोटो को एनग्रेव या ट्रेस करने से पहले ठीक करने के लिए **Tools - +Adjust Image...** चुनें: + +- **Brightness** (चमक) छवि को हल्का या गहरा करता है। +- **Contrast** (कंट्रास्ट) टोन को मध्य-ग्रे से दूर फैलाता है या उसकी ओर खींचता है। +- **Gamma** 1 से ऊपर मध्य-टोन को हल्का करता है, 1 से नीचे उन्हें गहरा करता है; काला और सफ़ेद जैसे + हैं वैसे ही रहते हैं। +- **Sharpen** (शार्पन) किनारों और बारीक विवरणों को मज़बूत करता है (एक अनशार्प मास्क)। **Sharpen + Radius** उन विवरणों का आकार मिलीमीटर में तय करता है, इसलिए परिणाम किसी भी लाइन अंतराल या ज़ूम पर + एक जैसा रहता है। + +संपादन करते समय कैनवास बदलाव दिखाता है। **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..15fbba9cd 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,27 @@ 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 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..35a18dd3a 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,26 @@ Використовуйте це для літофаній і тиснення в операції гравіювання, де світлі області мають гравіюватися глибоко, або для гравіювання світлого зображення на темному тлі. +## Коригування зображення + +Виберіть одну або кілька заготовок і виберіть **Tools - Adjust Image...**, щоб виправити фото перед +гравіюванням або трасуванням: + +- **Brightness** (яскравість) освітлює або затемнює зображення. +- **Contrast** (контраст) розводить тони від середньо-сірого або зводить їх до нього. +- **Gamma** понад 1 освітлює середні тони, менше 1 затемнює їх; чорний і білий не змінюються. +- **Sharpen** (різкість) підсилює краї та дрібні деталі (нерізке маскування). **Sharpen Radius** + задає розмір цих деталей у міліметрах, тож результат однаковий за будь-якого інтервалу ліній чи + масштабу. + +Полотно показує зміни під час редагування. **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..4c199495f 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,22 @@ Image**。浅色区域变深,深色区域变浅;透明区域保持透明, 在雕刻操作中将其用于石版画和浮雕(浅色区域应雕刻得较深),或者在深色背景上雕刻浅色图案。 +## 调整图像 + +选择一个或多个工件,然后选择 **Tools - Adjust Image...**,在雕刻或描摹之前校正照片: + +- **Brightness**(亮度)使图像变亮或变暗。 +- **Contrast**(对比度)使色调远离或靠近中灰。 +- **Gamma** 大于 1 时提亮中间调,小于 1 时压暗中间调;黑色和白色保持不变。 +- **Sharpen**(锐化)增强边缘和细节(USM 锐化)。**Sharpen Radius** + 以毫米为单位设置这些细节的大小,因此无论行间距或缩放如何,结果都相同。 + +编辑时画布会实时显示更改。**Apply** 将其作为一个撤销步骤保留;不应用就关闭对话框会放弃更改, +**Reset** 将所有值恢复为中性。透明区域保持透明。 + +对话框打开时显示第一个所选工件的值,Apply 会为所有所选工件设置相同的值。锐化在色调校正之前进行,Invert +Image 等其他编辑保持原位。 + ## 旧项目 在此插件出现之前,反转是雕刻操作的一个设置。当您打开使用过该设置的旧项目时,Rayforge 会将该设置移动到该图层的工件上,因此输出保持不变。 From 6dcfdf2820aef4ff2d5d5f218d9ba14d4169e5d5 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Tue, 13 Oct 2026 13:27:45 +0200 Subject: [PATCH 4/7] perf(image-edit): bound the workpiece and source image caches The render and processed-image caches on a workpiece grew by one image per zoom level and per resize, and the source asset's base-image cache by up to ten images regardless of their size. On an 8000x6000 image ten zoom steps and ten resizes kept about 2.9 GB of pixels cached. Add a small thread-safe LruCache with an entry limit and a byte budget, and use it for all three caches: at most 4 workpiece renders and 4 processed images (10 base images per source), each cache limited to 128 MiB of pixels, least recently used first; the newest image is always kept. Edit changes still replace the processed cache instead of adding to it, so slider moves in the Adjust Image dialog keep a single cached result. Refs #515 --- .../tests/ui_gtk/test_adjust_dialog.py | 25 ++++++ rayforge/core/image_cache.py | 43 ++++++++++ rayforge/core/source_asset.py | 19 ++--- rayforge/core/workpiece.py | 19 +++-- rayforge/shared/util/cache.py | 78 ++++++++++++++++++- tests/core/test_workpiece_image_edits.py | 60 ++++++++++++++ tests/shared/test_shared_util_cache.py | 49 +++++++++++- 7 files changed, 274 insertions(+), 19 deletions(-) create mode 100644 rayforge/core/image_cache.py 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 index 56e9cb98b..f84ddb2df 100644 --- 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 @@ -3,6 +3,7 @@ import gi import pytest +import pyvips gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") @@ -80,3 +81,27 @@ def test_reset_removes_the_adjustments(dialog, workpieces): 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_row.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_row.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/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 c443d0e5f..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 @@ -104,14 +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, size). self._image_edits: tuple[ImageEditStep, ...] = () - self._processed_cache: dict[ + 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 @@ -871,7 +875,7 @@ def get_processed_image( processed = apply_image_edits( _with_resolution(image, size), self._image_edits ) - self._processed_cache[key] = processed + self._processed_cache.put(key, processed) return processed def _get_vips_image(self, width: int, height: int) -> pyvips.Image | None: @@ -880,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() @@ -923,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/tests/core/test_workpiece_image_edits.py b/tests/core/test_workpiece_image_edits.py index 0df99d213..f9789cafc 100644 --- a/tests/core/test_workpiece_image_edits.py +++ b/tests/core/test_workpiece_image_edits.py @@ -6,6 +6,11 @@ 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, @@ -176,3 +181,58 @@ def test_resizing_reapplies_the_edits(workpiece, resolution_op): 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..b7d8e51f1 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,50 @@ 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(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(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(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(max_entries=10, max_bytes=100, sizeof=lambda v: v) + cache.put("a", 40) + cache.clear() + assert len(cache) == 0 + assert cache.nbytes == 0 From 77f6193c36989bdfb2c5cd92390afd6b21f90999 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Tue, 13 Oct 2026 19:24:49 +0200 Subject: [PATCH 5/7] test(cache): type the byte-budget LruCache tests for pyright --- tests/shared/test_shared_util_cache.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/tests/shared/test_shared_util_cache.py b/tests/shared/test_shared_util_cache.py index b7d8e51f1..dc905745c 100644 --- a/tests/shared/test_shared_util_cache.py +++ b/tests/shared/test_shared_util_cache.py @@ -129,7 +129,9 @@ def test_evicts_least_recently_used_entry(self): assert set(cache.keys()) == {"a", "c"} def test_byte_budget_evicts_least_recently_used(self): - cache = LruCache(max_entries=10, max_bytes=100, sizeof=lambda v: v) + 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") @@ -138,21 +140,27 @@ def test_byte_budget_evicts_least_recently_used(self): assert cache.nbytes == 80 def test_keeps_newest_entry_even_over_budget(self): - cache = LruCache(max_entries=10, max_bytes=100, sizeof=lambda v: v) + 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(max_entries=10, max_bytes=100, sizeof=lambda v: v) + 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(max_entries=10, max_bytes=100, sizeof=lambda v: v) + cache = LruCache[str, int]( + max_entries=10, max_bytes=100, sizeof=lambda v: v + ) cache.put("a", 40) cache.clear() assert len(cache) == 0 From 7e4d2d49b23f69d757ad2395372f59e49a6970e6 Mon Sep 17 00:00:00 2001 From: Samuel Abels Date: Thu, 15 Oct 2026 00:50:28 +0200 Subject: [PATCH 6/7] refactor(image-edit): restyle the Adjust Image dialog The dialog opens non-modal and adopts the step settings dialog's structure: a toolbar view with the page in a scrolled window, 600 px wide, so the preferences margins match it exactly. Brightness, contrast, gamma and sharpen become sliders with percent entries like the laser page's power row, with the 250 ms preview debounce moving into the dialog. Refs #515 --- .../image_editing/adjust_dialog.py | 100 +++++++++++++----- .../tests/ui_gtk/test_adjust_dialog.py | 15 ++- 2 files changed, 81 insertions(+), 34 deletions(-) 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 index eb4e13d73..43b13fefa 100644 --- 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 @@ -3,14 +3,15 @@ from collections.abc import Sequence from gettext import gettext as _ -from gi.repository import Adw, Gtk +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, SpinRow +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 @@ -18,6 +19,9 @@ #: 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): """ @@ -37,15 +41,25 @@ def __init__( 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(440, -1) - self.set_modal(True) + self.set_default_size(DEFAULT_WIDTH, -1) - box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - box.append(self._build_header()) - box.append(self._build_page()) - self.set_content(box) + 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)) @@ -62,33 +76,45 @@ def _build_header(self) -> Adw.HeaderBar: header.pack_end(apply_btn) return header - def _spin_row(self, title, subtitle, lower, upper, step, digits): - row = SpinRow( - title, - subtitle, + 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, - numeric=True, - debounce_ms=PREVIEW_DELAY_MS, + on_value_changed=self._schedule_preview, + format_suffix=format_suffix, ) - row.value_changed.connect(lambda *a: self.preview()) - return row def _build_page(self) -> Adw.PreferencesPage: - self.brightness_row = self._spin_row( - _("Brightness"), _("Lighten or darken the image"), -100, 100, 1, 0 + 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._spin_row( + 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._spin_row( + self.gamma_row, self.gamma_scale = self._slider_row( _("Gamma"), _("Values above 1 lighten the midtones, below 1 darken them"), 0.2, @@ -96,13 +122,14 @@ def _build_page(self) -> Adw.PreferencesPage: 0.05, 2, ) - self.sharpen_row = self._spin_row( + 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"), @@ -128,20 +155,32 @@ def _build_page(self) -> Adw.PreferencesPage: def get_adjustments(self) -> ImageAdjustments: return ImageAdjustments( - brightness=self.brightness_row.get_value(), - contrast=self.contrast_row.get_value(), - gamma=self.gamma_row.get_value(), - sharpen_amount=self.sharpen_row.get_value(), + 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_row.set_value(values.brightness) - self.contrast_row.set_value(values.contrast) - self.gamma_row.set_value(values.gamma) - self.sharpen_row.set_value(values.sharpen_amount) + 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() @@ -164,6 +203,9 @@ def apply(self) -> None: 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] 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 index f84ddb2df..46a5fc0af 100644 --- 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 @@ -42,9 +42,14 @@ 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_row.set_value(20) + dialog.contrast_scale.set_value(20) dialog.preview() a, b = workpieces assert a.image_edits[0].params["contrast"] == 20 @@ -55,7 +60,7 @@ def test_preview_shows_without_undo(dialog, workpieces): @pytest.mark.ui def test_closing_without_apply_restores_the_edits(dialog, workpieces): - dialog.sharpen_row.set_value(100) + dialog.sharpen_scale.set_value(100) dialog.preview() dialog.close() assert workpieces[0].image_edits == (TONE,) @@ -65,7 +70,7 @@ def test_closing_without_apply_restores_the_edits(dialog, workpieces): @pytest.mark.ui def test_apply_is_one_undo_step(dialog, workpieces): history = dialog._history - dialog.gamma_row.set_value(1.5) + dialog.gamma_scale.set_value(1.5) dialog.preview() dialog.apply() assert workpieces[0].image_edits[0].params["gamma"] == 1.5 @@ -92,14 +97,14 @@ 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_row.set_value(value) + 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_row.set_value(100) + dialog.sharpen_scale.set_value(100) dialog.preview() for wp in workpieces: _cache_a_render(wp) From f758f72a55d5c8db603d9272802cae822a61f1ae Mon Sep 17 00:00:00 2001 From: Samuel Abels Date: Thu, 15 Oct 2026 01:25:21 +0200 Subject: [PATCH 7/7] docs(image-edit): show the Adjust Image dialog in the docs A new screenshot target stages the example photo, opens the dialog with the brightness, contrast and sharpen sliders set, and captures it as addon-image-editing-adjust.webp. The docs embed it after the Adjust Image settings, in every language. --- scripts/screenshot/adjust_image_dialog.py | 121 ++++++++++++++++++ scripts/screenshot/cli.py | 1 + website/docs/addons/image-editing.md | 2 + .../current/addons/image-editing.md | 2 + .../current/addons/image-editing.md | 2 + .../current/addons/image-editing.md | 2 + .../current/addons/image-editing.md | 2 + .../current/addons/image-editing.md | 2 + .../current/addons/image-editing.md | 2 + .../current/addons/image-editing.md | 2 + .../addon-image-editing-adjust.webp | Bin 0 -> 16362 bytes 11 files changed, 138 insertions(+) create mode 100644 scripts/screenshot/adjust_image_dialog.py create mode 100644 website/static/screenshots/addon-image-editing-adjust.webp 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/website/docs/addons/image-editing.md b/website/docs/addons/image-editing.md index cf9353ef9..30a2aa3a4 100644 --- a/website/docs/addons/image-editing.md +++ b/website/docs/addons/image-editing.md @@ -40,6 +40,8 @@ engraving or tracing it: 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. 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 2a2515e94..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 @@ -38,6 +38,8 @@ Gravieren oder Nachzeichnen zu korrigieren: 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. 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 5bfe515f0..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 @@ -36,6 +36,8 @@ antes de grabarla o vectorizarla: 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. 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 c2b8d00e1..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 @@ -36,6 +36,8 @@ photo avant de la graver ou de la vectoriser : 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. 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 bcca3faed..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 @@ -36,6 +36,8 @@ Adjust Image...** चुनें: Radius** उन विवरणों का आकार मिलीमीटर में तय करता है, इसलिए परिणाम किसी भी लाइन अंतराल या ज़ूम पर एक जैसा रहता है। +![टोन और शार्पनेस स्लाइडर के साथ छवि समायोजित करें डायलॉग](/screenshots/addon-image-editing-adjust.webp) + संपादन करते समय कैनवास बदलाव दिखाता है। **Apply** उन्हें एक ही अनडू चरण के रूप में रखता है; बिना लागू किए डायलॉग बंद करने पर वे हट जाते हैं, और **Reset** हर मान को फिर से तटस्थ कर देता है। पारदर्शी क्षेत्र पारदर्शी ही रहते हैं। 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 15fbba9cd..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 @@ -36,6 +36,8 @@ gravá-la ou vetorizá-la: 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. 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 35a18dd3a..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 @@ -35,6 +35,8 @@ задає розмір цих деталей у міліметрах, тож результат однаковий за будь-якого інтервалу ліній чи масштабу. +![Діалогове вікно коригування зображення з повзунками тону та різкості](/screenshots/addon-image-editing-adjust.webp) + Полотно показує зміни під час редагування. **Apply** зберігає їх як один крок скасування; якщо закрити діалог без застосування, вони відкидаються, а **Reset** повертає всі значення до нейтральних. Прозорі ділянки залишаються прозорими. 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 4c199495f..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 @@ -27,6 +27,8 @@ Image**。浅色区域变深,深色区域变浅;透明区域保持透明, - **Sharpen**(锐化)增强边缘和细节(USM 锐化)。**Sharpen Radius** 以毫米为单位设置这些细节的大小,因此无论行间距或缩放如何,结果都相同。 +![带有色调和锐化滑块的调整图像对话框](/screenshots/addon-image-editing-adjust.webp) + 编辑时画布会实时显示更改。**Apply** 将其作为一个撤销步骤保留;不应用就关闭对话框会放弃更改, **Reset** 将所有值恢复为中性。透明区域保持透明。 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 0000000000000000000000000000000000000000..44d89979699e7edebae519ed344fb00cb5fbaea2 GIT binary patch literal 16362 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 VTJS%J5#O%B{#UmAkK6C^{{g~*$m{?B literal 0 HcmV?d00001