Skip to content

feat(image-edit): tone and sharpen edits (Adjust Image) - #535

Merged
knipknap merged 7 commits into
barebaric:mainfrom
switch87:feat/image-adjustments
Oct 15, 2026
Merged

knipknap merged 7 commits into
barebaric:mainfrom
switch87:feat/image-adjustments

Conversation

@switch87

@switch87 switch87 commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Adds the photo corrections from #515 as image edits in the Image Editing addon, on top of the registry from #552. The edits live on the workpiece; the Engrave step and the pipeline do not know about them.

Added

Design

  • Two new ops, registered by the addon's worker like invert:
    • tone (brightness, contrast, gamma): one lookup table via maplut, for 8- and 16-bit images; alpha is kept.
    • sharpen (amount in %, radius_mm): an unsharp mask in pyvips. The blur runs on premultiplied colour, so transparent areas do not darken the edges.
  • Core change (small): WorkPiece.get_processed_image() tags the render with xres/yres (pixels per mm, from the workpiece size) before running the stack, so a 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. ImageEditOp.apply() documents this.
  • The dialog edits both ops at once. A value at its neutral default removes that op's edit. An op that is already on the stack keeps its position, and other edits (e.g. Invert) are left alone. Sharpen comes before tone.
  • Everything runs in pyvips, so there is no numpy/scipy image math and no new raygeo code. That replaces the raygeo.image.adjust follow-up discussed earlier.
  • No .po/.pot changes; the new strings are wrapped in _().

Not in this PR (split off)

  • Levels (auto levels, black/white point) stay on the Engrave step for now. Moving them changes existing documents, because every Engrave step defaults to auto levels, so they need a migration and the histogram widget has to move too. I'll send that as a separate PR on top of this one.
  • Processed-bitmap preview of the Engrave step's output (the dithered bitmap). It is step-specific rather than an image edit, so it will come as its own PR.

Tests

  • rayforge/builtin_addons/rayforge-addon-image-editing/tests (ops, adjustments model, commands, raster equivalence): 70 passed together with tests/core/test_workpiece_image_edits.py, tests/core/test_doc_image_edits.py and tests/core/image_edit
  • .../tests/ui_gtk/test_adjust_dialog.py (-m ui): 5 passed
  • tests/core tests/image tests/pipeline: 1946 passed
  • I also ran it in the app (headless): the action is enabled with a selection, the preview brightens the image (mean 124.5 to 196.5), Apply stores sharpen and tone, and one undo restores the original
  • ruff format/check, flake8 and pyflakes are clean

Refs #515

@knipknap

Copy link
Copy Markdown
Contributor

Thank you for the PR! Unfortunately this seems to introduce a duplicate copy of the dither algorithms previously also introduced with #534 - much of which is now in Raygeo. I suspect that to remove the Dither algorithm-specific code from this PR, additional Raygeo extensions may be required, such as exposing the Dither matrices through the public API. So this may be a larger change that is needed to avoid the duplication. On the plus side, it would likely make this PR smaller...

@switch87

Copy link
Copy Markdown
Contributor Author

I went looking for the dither duplication you flagged so I could move it to Raygeo, but I cannot find it in this PR — asking for a pointer before I start moving code, since the change you describe sounds large.

What I checked:

  • This PR does not touch rayforge/image/dither.py at all. git diff origin/main...name-only -- rayforge/image/dither.py comes back empty; the only files it changes are rayforge/image/adjust.py (new, tone + unsharp mask only, no dither), assembler_helpers.py, the laser addon's raster_step.py / raster_page.py / processed_preview.py, plus tests and docs.
  • The dither references in the diff are all uses of existing main code — from rayforge.image.dither import (DitherAlgorithm, ...) and calls into it — not a second copy of the algorithms.
  • assembler_helpers.py actually removes duplication: the three depth-mode branches each had their own surface_to_grayscale + invert block, and they now share one _adjusted_grayscale helper.

On the dither matrices specifically: the error-diffusion kernels are already delegated to Raygeo via KERNEL_NAMES in rayforge/image/dither.py. The one thing still living on the rayforge side is BAYER_MATRICES, but Raygeo's apply_bayer_dither(bayer_matrix: &[f32], ...) takes the matrix as an argument rather than owning it — so those are configuration, not a second implementation. And they belong to #514/#534 anyway, not to this PR.

So: could you point at the specific code you saw as a duplicate? If you were looking at #535 stacked on #534 rather than #535 against main, that would explain it — and in that case the right move is to do the Raygeo extraction on #534 (or after it lands) and rebase this one on top, rather than shrinking this PR.

Separately, this PR has the same release blocker as #532: it needs raygeo bumped past 1.62.0 once the Raygeo releases land.

@knipknap

knipknap commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

@switch87 Thanks for digging into this, and sorry for not pointing at specific lines right away. Two things conspired to make this hard to spot: the command as pasted (git diff origin/main...name-only) is missing a -- before name-only, so it likely errored rather than diffing anything — and since #534 landed in main after this PR branched (this PR opened Oct 9, #534 merged Oct 10), GitHub's "Files changed" tab for this PR is fed by a stale merge-base and mixes #534's refactor with this PR's additions.

The duplication I meant is on the current head (01302c64), in rayforge/image/dither.py. Next to the three functions it already delegates to raygeo (apply_floyd_steinberg_dither, apply_bayer_dither, apply_minimum_run_length), it re-implements in Python what raygeo main already ships as compiled Rust behind raygeo.image.dither:

head (01302c64), rayforge/image/dither.py already in raygeo
ERROR_DIFFUSION_KERNELS (L66) — all 8 kernels, weights & divisors identical to raygeo's src/image/dither.rs apply_error_diffusion_dither(grayscale, kernel, invert=False, serpentine=False), kernels by name: floyd_steinberg, atkinson, stucki, jarvis_judice_ninke, sierra, sierra_2row, sierra_lite, burkes
_diffuse_row (L205) + apply_error_diffusion (L236) same function — the generic kernel engine, serpentine included
_spot_function / _build_spot_cdf / _SPOT_CDF (L176–201), _build_newsprint_matrix / NEWSPRINT_MATRIX (L190–202) internals of the two screens below (same spot function, same 256-sample CDF, same 8×8 clustered-dot matrix on the 45° lattice)
apply_halftone (L285) apply_halftone_dither(grayscale, cell_size_mm, angle_degrees, pixels_per_mm=(1, 1), invert=False)
_apply_newsprint (L329) apply_newsprint_dither(grayscale, cell_size=1, pixels_per_mm=(1, 1), invert=False)
_error_diffusion_dither (L347) — linearizes via raygeo.image.srgb.srgb_to_linear first linearization is already built into raygeo's dither functions

The only spelling difference is the kernel name: raygeo calls it jarvis_judice_ninke while the enum keeps the short "jarvis" value — the KERNEL_NAMES mapping that post-#534 main carries in rayforge/image/dither.py already handles that.

The good news is that your proposed resolution is exactly what #534 did, and it is merged: main's grayscale_to_dithered_array() already accepts serpentine, halftone_cell_mm, halftone_angle and pixels_per_mm and dispatches everything to raygeo — the same keyword arguments your assembler_helpers.py passes. So rebasing onto current main should let this PR take rayforge/image/dither.py from main verbatim and drop it from the diff entirely; tests/image/test_dither_algorithms.py can then shrink to dispatch-level coverage, since raygeo's own suite (tests/image/test_image_dither.py) covers the algorithms themselves.

Two of your points I'll happily concede:

  • BAYER_MATRICES is configuration, not a second implementation — raygeo's apply_bayer_dither() takes the matrix as an argument by design. My earlier suspicion that raygeo would need to expose the matrices was wrong.
  • The _adjusted_grayscale() consolidation in assembler_helpers.py removes duplication rather than adding it. Nice cleanup.

And yes on the release blocker: the dither algorithms are on raygeo main but not in a release yet. Correction — I was wrong there: raygeo 1.62.0 is already on PyPI, and it contains all of this. The v1.62.0 tag sits on the merge that landed the dither algorithms, and the published wheel exposes apply_error_diffusion_dither, apply_halftone_dither and apply_newsprint_dither. So there is no release blocker: the PR can pin raygeo>=1.62.0 right away.

One genuine follow-up — not a blocker, and not duplication since it is brand-new code: rayforge/image/adjust.py's tone LUT and unsharp mask are exactly the kind of numeric image math that has lived in raygeo so far (compare rayforge/image/util/grayscale.py wrapping raygeo.image.convert). I'd like to see that move to a raygeo.image.adjust in a follow-up; note that raygeo deliberately has no scipy dependency, so the Gaussian blur would be implemented natively on the Rust side. But that shouldn't hold up this PR.

@knipknap

Copy link
Copy Markdown
Contributor

Correction to the release point in my comment above, which I've now fixed there as well: I claimed the dither algorithms were unreleased — that was wrong. raygeo 1.62.0 is already on PyPI, the v1.62.0 tag sits on the very merge that introduced the dither algorithms, and I've verified the published wheel exposes apply_error_diffusion_dither, apply_halftone_dither and apply_newsprint_dither. So there's nothing to wait for: this PR can pin raygeo>=1.62.0 today.

@switch87
switch87 force-pushed the feat/image-adjustments branch from 01302c6 to 7bd227b Compare October 11, 2026 16:09
@switch87

Copy link
Copy Markdown
Contributor Author

Rebased onto current main (7199db3) — and the duplication you pointed at disappeared without a single line of mine having to go: rayforge/image/dither.py is no longer in this PR's diff at all. #534's refactor had replaced the Python re-implementations with raygeo dispatch on the side my branch was cut from, so rebasing simply adopted main's version. What is left of the PR is its actual subject: rayforge/image/adjust.py, the adjustments parameter through preprocess_raster_image, the Engrave page controls, the processed-bitmap preview, the tests and the docs/i18n. No pin change either — main already pins raygeo==1.62.0.

Green after the rebase, in the pixi environment:

  • tests/image/test_adjust.py + tests/pipeline/stage/test_assembler_helpers.py — 53 passed
  • rayforge/builtin_addons/rayforge-addon-laser/tests/steps/test_raster_step.py + .../tests/ui_gtk/test_laser_pages.py — 59 passed
  • pixi run lint — ruff format/check, flake8, pyflakes, pyright and the metainfo check all clean

On the follow-up you asked for: agreed, the tone LUT and the unsharp mask belong in raygeo.image.adjust. I'll send that as a raygeo PR next and slim this module down to the wrapper, in the shape image/util/grayscale.py wraps raygeo.image.convert.

@knipknap

Copy link
Copy Markdown
Contributor

Honestly I think these image editing tools are not raster engraving specific, and it would be more natural to show the edited image on the canvas directly. Image editing is a generally useful tool - tracing as done by the contour step could benefit too - so hiding this in the raster step seems like the wrong location in the architecture.
Hence, instead of the defining them in the raster step, perhaps they are better implemented in the main canvas?

I am thinking a dedicated image editing tools in the Tool menu - and possibly even a completely new image editing addon for proper encapsulation + an image editing tool registry. As it is, the pipeline has to be aware of every single image editing mechanism, which is a clear architectural layer violation. The pipeline should be generic in this regard. It could have a generic "image preprocessing" step without knowing the pre-processing type - but I am not sure that is even needed here. The key question is: Do we want non-destructive image editing? If so, then the workpiece should probably carry a declarative description of the image editing steps, and could perhaps provide a cached convenience method to get the processed image. The alternative: destructive editing. This would mean baking the processed image into the workpiece. I tend towards non-destructive editing, as it is future proof and likely more in line with long term user expectations; the pipeline could then ask for the processed image.

So in short, I think this needs a design / plan that we should agree on first.

@switch87

Copy link
Copy Markdown
Contributor Author

Agreed on all three points — the raster step is the wrong home for this, the
pipeline shouldn't have to know what a "tone adjustment" is, and it should be
non-destructive. Here is the design I'd implement, grounded in patterns that
already exist in the codebase, followed by the decisions I'd like your call on
before I touch any code.

1. Where the state lives

WorkPiece already has exactly this pattern for geometry:

# rayforge/core/workpiece.py
# An optional override for the workpiece geometry. If set, this takes
# precedence over the source_segment's geometry. It allows for
# non-destructive editing (like splitting) without modifying the
# shared source segment.
self._edited_boundaries: Geometry | None = None

So the image edit stack becomes its sibling:

# rayforge/image/edit.py
@dataclass(frozen=True)
class ImageEditStep:
    """One non-destructive image editing operation."""
    op: str                     # registry key, stable across versions
    params: dict[str, Any]      # JSON-serializable, op-specific
    enabled: bool = True

WorkPiece._image_edits: list[ImageEditStep], serialised in to_dict() /
from_dict(), copied in in_world() (which is how subprocesses get hydrated),
and invalidated in clear_render_cache().

On the workpiece rather than the source asset, because one source can be
placed several times and edited independently — the same assumption
_edited_boundaries already makes.

2. The op registry

Same shape as the registries in rayforge/image/registry.py and
rayforge/pipeline/transformer/registry.py, including the addon cleanup that
AddonManager._unregister_addon_items already drives:

class ImageEditRegistry:
    def register(self, op_cls, *, addon_name=None): ...
    def unregister_all_from_addon(self, addon_name) -> int: ...
    def get(self, op_name) -> type[ImageEditOp] | None: ...

class ImageEditOp(ABC):
    NAME: ClassVar[str]        # serialisation key, never renamed
    LABEL: ClassVar[str]       # gettext-wrapped, for the UI
    @staticmethod
    def apply(image, params, *, pixels_per_mm, alpha=None): ...

Two properties this buys us:

  • Forward compatibility. An op whose class isn't registered (addon
    missing, older version) keeps its params on load and is simply skipped at
    apply time. This is the same known_keys / extra courtesy
    WorkPiece.from_dict already extends to unknown top-level keys.
  • Extensibility. A future op (background removal, a filter from an AI
    addon) registers itself and appears in the editor without core changes.

3. Execution — this is the layer-violation fix

Today the pipeline learns about image editing in two places:

  • assembler_helpers.preprocess_raster_image(..., adjustments=...) (line 313)
  • assembler_helpers.compute_raster_auto_levels(..., adjustments=...) (line 464)

both fed by RasterStep.image_adjustments. Both arguments disappear.

Instead the stack runs inside the one method every render already funnels
through:

render_for_ops ─┐
render_chunk ───┼─> render_to_pixels ─> get_vips_image
canvas element ─┤
layout, groups ─┤
print-and-cut ──┘          (sim3d calls get_vips_image directly)

Putting the stack there means all of them see the edited image automatically.
There is no call site to forget, so canvas, pipeline and 3D preview cannot
drift apart — which is the point of showing the edited image on the canvas.

Proposed order, appended to what get_vips_image already does:

raw render  (SourceAsset base cache — unchanged, still shared)
  -> crop -> mask -> invert     # _process_rendered_image_from_spec, unchanged
  -> apply_edit_stack           # new
  -> _render_cache              # key gains the stack signature

Cache key becomes (width, height, signature); mutating _image_edits clears
_render_cache, exactly the way the source_segment setter does today.

compute_raster_auto_levels then measures the already-edited image and needs
no adjustments argument — same measurement as today (it currently renders
and applies adjust_tone itself), half the plumbing.

Colour semantics. The built-in ops convert to luminance internally.
Today they are fed a grayscale conversion of the same render, so behaviour is
identical; apply_image_adjustments already restores fully transparent pixels
and would keep doing so. An op that genuinely needs colour can declare
otherwise.

Format. get_vips_image returns pyvips.Image while adjust.py works on
2D uint8 numpy, so the built-in ops bridge vips -> numpy -> vips. That
conversion moves out of assembler_helpers and into the ops.

4. UI

The _Tools menu already has an addon section fed by
action_registry.get_menu_items("tools") (main_menu.py:172-179, populated at
243-253), so an entry needs no new menu machinery. It opens a dock or dialog
listing the ordered stack — add / reorder / remove / toggle — acting on the
current selection.

The five controls move off the Engrave page (raster_page.py) into that
editor, and their .pot/.po strings move with them rather than being
retranslated in eight languages.

5. What happens to this PR

Nothing in #535 has shipped: brightness, contrast, gamma, sharpen_*
and rayforge/image/adjust.py are all new in this PR and absent from
origin/main. So there is no compatibility burden and no reason to land the
step-scoped shape first and then move it.

  • drop the five fields and image_adjustments (raster_step.py:400-437) and
    the two adjustments= arguments (976-1004)
  • drop both adjustments parameters from assembler_helpers (313, 464)
  • move the five controls out of raster_page.py
  • add _image_edits + signature to get_vips_image / _render_cache
  • to_dict / from_dict / in_world / clear_render_cache
  • registry + ImageEditOp + tone and unsharp_mask
  • Tools-menu action + editor
  • move the existing tests (tests/image/test_adjust.py, the adjustment tests
    in test_raster_step.py) to the new seams

The raygeo split you asked for earlier fits here as layer 1: the pure
implementations — tone LUT and unsharp mask with a native Gaussian blur
instead of scipy.ndimage — move to raygeo.image.adjust in its own PR, and
rayforge's tone / unsharp_mask ops become thin registrations over them.
That keeps the implementations unconditionally available and gets scipy out of
rayforge.

Decisions I'd like your call on

  1. Ops registered from core, or from an addon? The implementations land in
    raygeo either way. Registering them from rayforge core means a document
    with tone/sharpen edits renders identically everywhere and cannot be
    silently changed by disabling an addon — and core still doesn't know op
    types, only the registry, so the layer violation stays fixed. A separate
    rayforge-addon-image-edit gives tighter encapsulation but means an
    unknown op gets skipped and the render changes. I lean core-for-registry,
    with the editor UI free to live in an addon.

  2. Where is the boundary of "image editing"? Brightness / contrast /
    gamma / sharpen clearly move. auto_levels, black_point and white_point
    are levels, which are arguably image editing too but are tied to laser power
    mapping — I'd leave them on the raster step unless you disagree.

  3. Rework feat(image-edit): tone and sharpen edits (Adjust Image) #535, or merge as-is and follow up? I'd rework this PR: merging
    the step-scoped shape and then relocating it churns the i18n catalogs and
    engrave.md twice. But it's your call whether you'd rather get the
    current, tested shape in first.

  4. Base image access. You asked for "a cached convenience method to get the
    processed image". get_vips_image already is one, and putting the stack
    inside it gives WYSIWYG for free across all ten call sites. The more literal
    reading is a separate get_processed_image() that callers opt into, leaving
    get_vips_image as the untouched base — but then every caller has to
    remember, and one miss means canvas and output disagree. Which do you want?

@knipknap

knipknap commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

I don't understand the "both fed by RasterStep.image_adjustments" part. Why does the raster step still play a role here?

And about this:

raw render  (SourceAsset base cache — unchanged, still shared)
  -> crop -> mask -> invert     # _process_rendered_image_from_spec, unchanged
  -> apply_edit_stack           # new
  -> _render_cache              # key gains the stack signature

I think it would be cleaner to start with a separate refactor PR first, one that introduces the image editing tool registry, and turns the invert into such a generic tool - stripping the invert setting out of the raster step. This would allow to refine the architecture before committing to the full implementation of the additional image editing tools - which should be straight forward once the overall architecture is in place.

About your questions:

  1. Good point - image tools in an addon + declarative editing means some projects cannot apply the project's edits without the addon. But we have precedent here: Already the app notifies a user when he opens a project that requires an addon that is not installed. (e.g. when the laser essentials addon is inactive). So the same approach could be used here - and any not-present edit tool would thereafter be silently skipped, allowing for the project to still be used/edited.
  2. auto_levels, black_point and white_point should also move if they manipulate the bitmap. Not to be confused with the auto level feature detection - that is not editing.
  3. As proposed: Separate PR for the initial architecture without introducing new editing tools first, then this current PR can be reworked on top of that after a merge
  4. I am not sure here. get_vips_image sounds wrong, as I assume not all importers use vips. squeezing all into one function seems like a lack of decomposition making testability hard, so two functions make sense - and possibly _get_vips_image() could be made private. All callers will need updating, of course.

@switch87

Copy link
Copy Markdown
Contributor Author

On your question about "both fed by RasterStep.image_adjustments": that described where this PR stands today, not the target. Today the raster step owns the adjustments and passes them into preprocess_raster_image / compute_raster_auto_levels, and that is exactly the coupling that should go. In the design the step has no role at all: the edits live on the workpiece, and the step just renders it.

The refactor you asked for is up as #552: the image edit registry, _get_vips_image() plus get_processed_image(), an Image Editing addon, and Invert turned into the first edit and removed from the Engrave step (older projects are migrated). It adds no new tools. Once it lands, I'll rework this PR on top of it, with brightness/contrast/gamma/sharpen and the levels as edits.

One note: #552 is a draft only because GitHub would not let me open it, or mark it ready, as a regular PR (does not have the correct permissions). I assume there is a limit on open PRs per contributor. Please treat it as ready for review, or tell me if you would rather I close one of my other open PRs first.

@knipknap

Copy link
Copy Markdown
Contributor

Thanks again. Now that main has the image editing addon, I think this PR can be rebased to add the new tools.

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 barebaric#515
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 barebaric#515
@switch87
switch87 force-pushed the feat/image-adjustments branch from 455a424 to 60a6b0d Compare October 13, 2026 06:17
@switch87 switch87 changed the title Feat/image adjustments feat(image-edit): tone and sharpen edits (Adjust Image) Oct 13, 2026
@switch87

Copy link
Copy Markdown
Contributor Author

Rebuilt on current main, on top of the image edit registry. The old step-based commits are gone. Brightness, contrast, gamma and sharpen are now two edits in the Image Editing addon, tone and sharpen, edited together through Tools > Adjust Image... (live canvas preview, one undo step). The Engrave step, assembler_helpers and the dither code are untouched.

The only core change: get_processed_image() now tags the image with xres/yres in pixels per mm before running the stack, so the sharpen radius stays in mm and the canvas matches the output. Both ops are plain pyvips, so the raygeo.image.adjust follow-up and the scipy use are no longer needed.

To keep this one reviewable I split off two parts:

  • Levels (auto/black/white point) as an edit. Every Engrave step defaults to auto levels, so moving them needs a document migration and moves the histogram widget. That will be a separate PR on top of this one.
  • The processed-bitmap preview of the step output. It is step-specific, so it gets its own PR.

One question: is a single "Adjust Image" dialog that edits both ops what you had in mind, or would you prefer one menu entry per tool?

@knipknap

Copy link
Copy Markdown
Contributor

Thanks for updating the PR.

I am currently traveling so cannot test, but reading the code this now looks fine, architecture wise. Just one point: I worry about memory ballooning, as I did not see a cache retention policy on the workpiece. On large images this could lead to too much cache.

Regarding your suggestion for a follow up PR for a preview dialog - I am not sure why this would help, as I would have assumed this to be obsolete due to the on canvas editing.

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 barebaric#515
@switch87

Copy link
Copy Markdown
Contributor Author

Thanks, good catch. Measured on an 8000x6000 RGBA image: slider moves were already fine (changing the edits replaces the processed cache), but every zoom level and every resize added an entry to the workpiece's render and processed caches, and the source asset kept up to 10 base images of any size. Ten zoom steps plus ten resizes ended with ~2.9 GB of pixels cached (RSS ~1.8 GB).

Pushed 6dcfdf2 with a retention policy:

  • New LruCache in rayforge/shared/util/cache.py (thread-safe, entry limit plus byte budget, least recently used evicted first, the newest entry is always kept).
  • rayforge/core/image_cache.py sets the policy: per workpiece at most 4 unedited renders and 4 processed images, each cache capped at 128 MiB of uncompressed pixels. The source asset's base-image cache uses the same byte budget with its existing 10-entry limit.
  • Same scenario after the change: about 0.2 to 0.5 GB cached (one full-resolution image is 183 MiB and is kept as the newest entry), RSS ~0.9 GB.
  • Edit changes still clear the processed cache, so undo/redo and the dialog behave as before, and repeated draws at the same size still hit the cache.

Tests that pin it:

  • tests/shared/test_shared_util_cache.py::TestLruCache (LRU order, byte budget, newest kept over budget, size accounting)
  • tests/core/test_workpiece_image_edits.py: test_slider_changes_keep_one_processed_image, test_zooming_keeps_a_bounded_number_of_images, test_resizing_keeps_a_bounded_number_of_images, test_byte_budget_evicts_the_least_recently_used, test_source_base_images_have_a_byte_budget
  • tests/ui_gtk/test_adjust_dialog.py (image-editing addon): test_previews_do_not_accumulate_cached_images, test_closing_drops_the_preview_images

About the preview dialog: you're right. The on-canvas editing already shows the result live, so a separate preview dialog isn't needed. I won't follow that up.

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 barebaric#515
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.
@knipknap

Copy link
Copy Markdown
Contributor

Thank you for the PR. I made some refinements because testing it manually I discovered some issues, but I left the general approach untouched. Thanks again!

@knipknap
knipknap merged commit fa1c330 into barebaric:main Oct 15, 2026
32 checks passed
@switch87
switch87 deleted the feat/image-adjustments branch October 15, 2026 03:47

This branch was successfully deployed

1 active deployment
pypi — f758f72a Deployed Oct 14, 2026 by knipknap via Build and Publish Wheel #2304
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants