From a60df616c8a32ce62a9921b50a570dcd3cb83f5c Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Fri, 9 Oct 2026 12:46:48 +0200 Subject: [PATCH 1/6] fix(ui): show enum-backed step values in their choice rows The step settings pages pushed raw attribute values into the varset rows. For enum-backed attributes such as EngraveStep.dither_algorithm the combo adapter got "DitherAlgorithm.BAYER4", matched no entry and kept showing the first one (Floyd Steinberg), both when the dialog opened and after undo or an external change. Serialize the values with Step.recipe_value(), the same way recipes store them, before handing them to the rows. --- CHANGELOG.md | 3 ++ .../tests/ui_gtk/test_laser_pages.py | 22 ++++++++++++ .../doceditor/step_settings/pages/base.py | 34 +++++++++---------- 3 files changed, 42 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 283c89fe6..b72239885 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Step settings: choice rows backed by an enum (such as the Engrave + step's dithering algorithm) now show the step's actual value when + the dialog opens and after undo, instead of the first entry - Ruida: the setup wizard no longer requires the optional USB device and Magic fields, so Next is enabled again once a valid hostname is entered; the Magic field is prefilled with the standard 0x88 diff --git a/rayforge/builtin_addons/rayforge-addon-laser/tests/ui_gtk/test_laser_pages.py b/rayforge/builtin_addons/rayforge-addon-laser/tests/ui_gtk/test_laser_pages.py index c5c44e9bd..3abb0501e 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/tests/ui_gtk/test_laser_pages.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/tests/ui_gtk/test_laser_pages.py @@ -19,6 +19,7 @@ from laser_essentials.widgets.raster_power_widget import RasterPowerWidget from rayforge.core.step_registry import step_registry +from rayforge.image.dither import DitherAlgorithm from rayforge.pipeline.stage.assembler_helpers import DepthMode from rayforge.ui_gtk.doceditor.step_settings.dialog import StepSettingsDialog from rayforge.ui_gtk.doceditor.step_settings.pages import StepSettingsPage @@ -461,6 +462,27 @@ def test_raster_page_mode_visibility(editor, laser_machine, ui_context): assert sample_interval.get_visible() is True +@pytest.mark.ui +def test_raster_page_shows_the_steps_dither_algorithm( + editor, laser_machine, ui_context +): + """The step stores the algorithm as an enum; the combo row must + still select it, on open and after an external change.""" + step_cls = step_registry.get("EngraveStep") + assert step_cls is not None + step = cast(EngraveStep, step_cls.create(ui_context)) + step.depth_mode = "DITHER" + step.dither_algorithm = DitherAlgorithm.BAYER4 + page = RasterSettingsPage(editor, step) + + adapter = page.engrave_widget.adapter_for("dither_algorithm") + assert adapter is not None + assert adapter.get_value() == "BAYER4" + + step.set_dither_algorithm(DitherAlgorithm.STUCKI) + assert adapter.get_value() == "STUCKI" + + def _depth_mode_adapter(page: RasterSettingsPage) -> ComboAdapter: adapter = page.engrave_widget.adapter_for("depth_mode") assert isinstance(adapter, ComboAdapter) diff --git a/rayforge/ui_gtk/doceditor/step_settings/pages/base.py b/rayforge/ui_gtk/doceditor/step_settings/pages/base.py index e0b1cd0b2..820566fc0 100644 --- a/rayforge/ui_gtk/doceditor/step_settings/pages/base.py +++ b/rayforge/ui_gtk/doceditor/step_settings/pages/base.py @@ -86,10 +86,7 @@ def _on_recipe_applied(self, *args): # edits and push the applied values into the rows. for widget, var_set in self._varset_widgets: widget.cancel_pending() - values = { - var.key: getattr(self.step, var.key, None) for var in var_set - } - widget.set_values(values) + widget.set_values(self._model_values(var_set)) self._update_machine_bounds() def get_machine(self) -> "Machine | None": @@ -170,9 +167,7 @@ def add_varset_section( if description: widget.set_description(escape_title(description)) widget.populate(var_set) - widget.set_values( - {var.key: getattr(self.step, var.key, None) for var in var_set} - ) + widget.set_values(self._model_values(var_set)) widget.data_changed.connect(self._on_varset_data_changed) self.add(widget) self._sections.append(widget) @@ -195,6 +190,19 @@ def _varset_for_keys( ) return VarSet(vars=[var for var in var_set if var.key in keys]) + def _model_values(self, var_set: VarSet) -> dict[str, Any]: + """The step's current values for the rows of ``var_set``. + + Enum-backed attributes are serialized the same way recipes + store them, so choice rows can match them to their entries. + """ + return { + var.key: self.step.recipe_value( + var.key, getattr(self.step, var.key, None) + ) + for var in var_set + } + def _on_varset_data_changed(self, widget: VarSetWidget, key: str): value = widget.get_values().get(key) self.set_step_property(key, value) @@ -207,10 +215,7 @@ def _sync_widgets_to_model(self, *args): :meth:`_on_recipe_applied`. """ for widget, var_set in self._varset_widgets: - values = { - var.key: getattr(self.step, var.key, None) for var in var_set - } - widget.sync_from_model(values) + widget.sync_from_model(self._model_values(var_set)) self._update_machine_bounds() def _on_config_changed(self, *args): @@ -227,12 +232,7 @@ def _on_config_changed(self, *args): if new_var_set is None: continue widget.populate(new_var_set) - widget.set_values( - { - var.key: getattr(self.step, var.key, None) - for var in new_var_set - } - ) + widget.set_values(self._model_values(new_var_set)) self._varset_widgets[i] = (widget, new_var_set) self._on_machine_changed() From 6f5f647abee0ab70fd542327fe7a6a3962e4f056 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Fri, 9 Oct 2026 12:46:55 +0200 Subject: [PATCH 2/6] feat(image): add error-diffusion kernels and halftone screens Extend DitherAlgorithm with Atkinson, Stucki, Jarvis-Judice-Ninke, Sierra (3-row, 2-row, Lite) and Burkes error diffusion, a clustered-dot Newsprint screen and a round-dot Halftone screen. - apply_error_diffusion() takes any forward kernel and an optional serpentine scan. The error within a row is propagated per pixel, the error for the following rows with one vectorised add per kernel tap, which dithers a 2.5 MP image in about 0.6 s. Like raygeo's Floyd-Steinberg it works in linear light. - apply_halftone() lays a cosine spot screen out in millimetres, so the dots stay round on the non-square raster pixels, and ranks the spot values so the dot area follows the darkness. - Newsprint tiles a 45 degree clustered-dot matrix whose cells scale with the minimum feature size and stay square in millimetres. Floyd-Steinberg without serpentine and the Bayer screens keep using raygeo, so existing jobs produce identical output. Refs #514 --- rayforge/image/dither.py | 344 +++++++++++++++++++++++- tests/image/test_dither_algorithms.py | 363 ++++++++++++++++++++++++++ 2 files changed, 699 insertions(+), 8 deletions(-) create mode 100644 tests/image/test_dither_algorithms.py diff --git a/rayforge/image/dither.py b/rayforge/image/dither.py index 1a0d64881..83897fb08 100644 --- a/rayforge/image/dither.py +++ b/rayforge/image/dither.py @@ -1,5 +1,6 @@ """Dithering algorithms for converting grayscale images to binary.""" +import math from enum import Enum from gettext import gettext as _ @@ -10,24 +11,145 @@ apply_floyd_steinberg_dither, apply_minimum_run_length, ) +from raygeo.image.srgb import srgb_to_linear + +DEFAULT_HALFTONE_CELL_MM = 0.5 +DEFAULT_HALFTONE_ANGLE = 45.0 + +#: Rows processed per block when building the halftone screen, to +#: bound the memory of the per-pixel coordinate arrays. +_HALFTONE_BLOCK_ROWS = 256 class DitherAlgorithm(Enum): FLOYD_STEINBERG = "floyd_steinberg" + ATKINSON = "atkinson" + STUCKI = "stucki" + JARVIS_JUDICE_NINKE = "jarvis" + SIERRA = "sierra" + SIERRA_2ROW = "sierra_2row" + SIERRA_LITE = "sierra_lite" + BURKES = "burkes" BAYER2 = "bayer2" BAYER4 = "bayer4" BAYER8 = "bayer8" + NEWSPRINT = "newsprint" + HALFTONE = "halftone" @property def display_name(self) -> str: names = { self.FLOYD_STEINBERG: _("Floyd Steinberg"), + self.ATKINSON: _("Atkinson"), + self.STUCKI: _("Stucki"), + self.JARVIS_JUDICE_NINKE: _("Jarvis, Judice & Ninke"), + self.SIERRA: _("Sierra"), + self.SIERRA_2ROW: _("Sierra 2-Row"), + self.SIERRA_LITE: _("Sierra Lite"), + self.BURKES: _("Burkes"), self.BAYER2: _("Bayer 2"), self.BAYER4: _("Bayer 4"), self.BAYER8: _("Bayer 8"), + self.NEWSPRINT: _("Newsprint"), + self.HALFTONE: _("Halftone"), } return names[self] + @property + def is_error_diffusion(self) -> bool: + return self in ERROR_DIFFUSION_KERNELS + + +#: Error-diffusion kernels as ``(divisor, [(dx, dy, weight), ...])``. +#: ``dx`` is relative to the scan direction, so serpentine rows mirror +#: it. Atkinson deliberately diffuses only 6/8 of the error. +ERROR_DIFFUSION_KERNELS: dict[ + DitherAlgorithm, tuple[int, list[tuple[int, int, int]]] +] = { + DitherAlgorithm.FLOYD_STEINBERG: ( + 16, + [(1, 0, 7), (-1, 1, 3), (0, 1, 5), (1, 1, 1)], + ), + DitherAlgorithm.ATKINSON: ( + 8, + [(1, 0, 1), (2, 0, 1), (-1, 1, 1), (0, 1, 1), (1, 1, 1), (0, 2, 1)], + ), + DitherAlgorithm.STUCKI: ( + 42, + [ + (1, 0, 8), + (2, 0, 4), + (-2, 1, 2), + (-1, 1, 4), + (0, 1, 8), + (1, 1, 4), + (2, 1, 2), + (-2, 2, 1), + (-1, 2, 2), + (0, 2, 4), + (1, 2, 2), + (2, 2, 1), + ], + ), + DitherAlgorithm.JARVIS_JUDICE_NINKE: ( + 48, + [ + (1, 0, 7), + (2, 0, 5), + (-2, 1, 3), + (-1, 1, 5), + (0, 1, 7), + (1, 1, 5), + (2, 1, 3), + (-2, 2, 1), + (-1, 2, 3), + (0, 2, 5), + (1, 2, 3), + (2, 2, 1), + ], + ), + DitherAlgorithm.SIERRA: ( + 32, + [ + (1, 0, 5), + (2, 0, 3), + (-2, 1, 2), + (-1, 1, 4), + (0, 1, 5), + (1, 1, 4), + (2, 1, 2), + (-1, 2, 2), + (0, 2, 3), + (1, 2, 2), + ], + ), + DitherAlgorithm.SIERRA_2ROW: ( + 16, + [ + (1, 0, 4), + (2, 0, 3), + (-2, 1, 1), + (-1, 1, 2), + (0, 1, 3), + (1, 1, 2), + (2, 1, 1), + ], + ), + DitherAlgorithm.SIERRA_LITE: (4, [(1, 0, 2), (-1, 1, 1), (0, 1, 1)]), + DitherAlgorithm.BURKES: ( + 32, + [ + (1, 0, 8), + (2, 0, 4), + (-2, 1, 2), + (-1, 1, 4), + (0, 1, 8), + (1, 1, 4), + (2, 1, 2), + ], + ), +} + BAYER_MATRICES = { DitherAlgorithm.BAYER2: np.array([[0, 2], [3, 1]], dtype=np.float32), @@ -51,11 +173,201 @@ def display_name(self) -> str: } +def _spot_function(u: np.ndarray, v: np.ndarray) -> np.ndarray: + """Round-dot screen spot function in cell units, 1 at dot centres + and 0 halfway between them.""" + return (np.cos(2.0 * np.pi * u) + np.cos(2.0 * np.pi * v)) / 4.0 + 0.5 + + +def _build_spot_cdf(samples: int = 256) -> np.ndarray: + """Sorted spot values over one cell, used to turn a spot value + into the fraction of the cell it covers.""" + grid = (np.arange(samples) + 0.5) / samples + u, v = np.meshgrid(grid, grid) + return np.sort(_spot_function(u, v).ravel()) + + +def _build_newsprint_matrix(size: int = 8) -> np.ndarray: + """Clustered-dot threshold ranks with the dots on a 45° lattice.""" + grid = (np.arange(size) + 0.5) / size + u, v = np.meshgrid(grid, grid) + spot = _spot_function(u + v, u - v) + order = np.argsort(-spot.ravel(), kind="stable") + ranks = np.empty(size * size, dtype=np.int32) + ranks[order] = np.arange(size * size) + return ranks.reshape(size, size) + + +_SPOT_CDF = _build_spot_cdf() +NEWSPRINT_MATRIX = _build_newsprint_matrix() + + +def _diffuse_row( + row: np.ndarray, + forward: tuple[float, float], + reverse: bool, +) -> tuple[np.ndarray, np.ndarray]: + """Quantise one row, pushing the error to the next one or two + pixels in scan direction. Returns ``(errors, dark)`` in image + order.""" + values = (row[::-1] if reverse else row).tolist() + width = len(values) + values.extend((0.0, 0.0)) + w1, w2 = forward + errors = [0.0] * width + dark = [0] * width + for x in range(width): + value = values[x] + if value < 0.5: + dark[x] = 1 + error = value + else: + error = value - 1.0 + errors[x] = error + values[x + 1] += error * w1 + values[x + 2] += error * w2 + errors_arr = np.asarray(errors) + dark_arr = np.asarray(dark, dtype=np.uint8) + if reverse: + return errors_arr[::-1], dark_arr[::-1] + return errors_arr, dark_arr + + +def apply_error_diffusion( + values: np.ndarray, + kernel: tuple[int, list[tuple[int, int, int]]], + serpentine: bool = False, +) -> np.ndarray: + """ + Error-diffusion dithering with an arbitrary forward kernel. + + The error within a row is propagated pixel by pixel; the error for + the following rows is spread with one vectorised add per kernel + tap once the row is done. + + Args: + values: 2D float array of intensities in [0, 1] (1 = white). + kernel: ``(divisor, [(dx, dy, weight), ...])`` with + ``dy == 0`` taps limited to ``dx`` 1 and 2. + serpentine: Scan odd rows right-to-left, mirroring the kernel. + + Returns: + Binary uint8 array where 1 marks pixels to engrave. + """ + height, width = values.shape + out = np.zeros((height, width), dtype=np.uint8) + if height == 0 or width == 0: + return out + divisor, taps = kernel + forward = [0.0, 0.0, 0.0] + later = [] + for dx, dy, weight in taps: + if dy == 0: + forward[dx] = weight / divisor + else: + later.append((dx, dy, weight / divisor)) + pad = max((abs(dx) for dx, _dy, _w in later), default=0) + extra_rows = max((dy for _dx, dy, _w in later), default=0) + work = np.zeros((height + extra_rows, width + 2 * pad)) + work[:height, pad : pad + width] = values + for y in range(height): + reverse = serpentine and y % 2 == 1 + errors, dark = _diffuse_row( + work[y, pad : pad + width], (forward[1], forward[2]), reverse + ) + out[y] = dark + for dx, dy, weight in later: + start = pad + (-dx if reverse else dx) + work[y + dy, start : start + width] += errors * weight + return out + + +def apply_halftone( + grayscale: np.ndarray, + cell_mm: float, + angle_deg: float, + pixels_per_mm: tuple[float, float], +) -> np.ndarray: + """ + Amplitude-modulated halftone screen with round dots. + + The screen is laid out in millimetres, so the dots stay round on + non-square pixels. Dot area tracks darkness: a pixel is engraved + when its brightness is below the share of the cell that the spot + function ranks at or under it. + + Args: + grayscale: 2D uint8 grayscale array (0-255). + cell_mm: Distance between dot centres in mm. + angle_deg: Screen angle in degrees. + pixels_per_mm: ``(x, y)`` image resolution. + + Returns: + Binary uint8 array where 1 marks pixels to engrave. + """ + height, width = grayscale.shape + ppm_x, ppm_y = pixels_per_mm + if cell_mm <= 0: + cell_mm = 1.0 / max(ppm_x, ppm_y) + theta = math.radians(angle_deg) + cos_t = math.cos(theta) / cell_mm + sin_t = math.sin(theta) / cell_mm + x_mm = (np.arange(width) + 0.5) / ppm_x + out = np.empty((height, width), dtype=np.uint8) + for top in range(0, height, _HALFTONE_BLOCK_ROWS): + bottom = min(height, top + _HALFTONE_BLOCK_ROWS) + y_mm = ((np.arange(top, bottom) + 0.5) / ppm_y)[:, None] + u = x_mm * cos_t + y_mm * sin_t + v = y_mm * cos_t - x_mm * sin_t + spot = _spot_function(u, v) + coverage = (np.searchsorted(_SPOT_CDF, spot) + 0.5) / _SPOT_CDF.size + brightness = grayscale[top:bottom] / 255.0 + out[top:bottom] = brightness < coverage + return out + + +def _apply_newsprint( + grayscale: np.ndarray, + min_feature_px: int, + pixels_per_mm: tuple[float, float], +) -> np.ndarray: + """Tile the clustered-dot matrix with physically square cells.""" + height, width = grayscale.shape + size = NEWSPRINT_MATRIX.shape[0] + ppm_x, ppm_y = pixels_per_mm + cell_x = max(1, min_feature_px) + cell_y = max(1, round(cell_x * ppm_y / ppm_x)) + rows = (np.arange(height) // cell_y) % size + cols = (np.arange(width) // cell_x) % size + ranks = NEWSPRINT_MATRIX[rows[:, None], cols[None, :]] + thresholds = 255.0 * (1.0 - (ranks + 0.5) / (size * size)) + return (grayscale < thresholds).astype(np.uint8) + + +def _error_diffusion_dither( + grayscale: np.ndarray, + dither_algorithm: DitherAlgorithm, + serpentine: bool, +) -> np.ndarray: + """Dither in linear light, like raygeo's Floyd-Steinberg.""" + linear = srgb_to_linear(np.ascontiguousarray(grayscale, dtype=np.uint8)) + return apply_error_diffusion( + linear.astype(np.float64), + ERROR_DIFFUSION_KERNELS[dither_algorithm], + serpentine, + ) + + def grayscale_to_dithered_array( grayscale: np.ndarray, dither_algorithm: DitherAlgorithm, invert: bool = False, min_feature_px: int = 1, + *, + serpentine: bool = False, + halftone_cell_mm: float = DEFAULT_HALFTONE_CELL_MM, + halftone_angle: float = DEFAULT_HALFTONE_ANGLE, + pixels_per_mm: tuple[float, float] = (1.0, 1.0), ) -> np.ndarray: """ Convert a grayscale array to a dithered binary array. @@ -65,20 +377,36 @@ def grayscale_to_dithered_array( dither_algorithm: The dithering algorithm to use. invert: If True, invert the output (engrave light areas). min_feature_px: Minimum feature size in pixels. + serpentine: Alternate the scan direction per row + (error-diffusion algorithms only). + halftone_cell_mm: Halftone dot spacing in mm. + halftone_angle: Halftone screen angle in degrees. + pixels_per_mm: ``(x, y)`` image resolution, used by the + screens to keep their cells square in millimetres. Returns: Binary image where 1 represents areas to engrave. """ - if dither_algorithm == DitherAlgorithm.FLOYD_STEINBERG: + if dither_algorithm == DitherAlgorithm.FLOYD_STEINBERG and not serpentine: bw_image = apply_floyd_steinberg_dither(grayscale, invert) - bw_image = apply_minimum_run_length(bw_image, min_feature_px) - else: - bayer_matrix = BAYER_MATRICES[dither_algorithm] - bw_image = apply_bayer_dither( - grayscale, bayer_matrix, invert, cell_size=min_feature_px + return apply_minimum_run_length(bw_image, min_feature_px) + if dither_algorithm in BAYER_MATRICES: + return apply_bayer_dither( + grayscale, + BAYER_MATRICES[dither_algorithm], + invert, + cell_size=min_feature_px, ) - - return bw_image + if invert: + grayscale = 255 - grayscale + if dither_algorithm == DitherAlgorithm.HALFTONE: + return apply_halftone( + grayscale, halftone_cell_mm, halftone_angle, pixels_per_mm + ) + if dither_algorithm == DitherAlgorithm.NEWSPRINT: + return _apply_newsprint(grayscale, min_feature_px, pixels_per_mm) + bw_image = _error_diffusion_dither(grayscale, dither_algorithm, serpentine) + return apply_minimum_run_length(bw_image, min_feature_px) def surface_to_dithered_array( diff --git a/tests/image/test_dither_algorithms.py b/tests/image/test_dither_algorithms.py new file mode 100644 index 000000000..73991d0c8 --- /dev/null +++ b/tests/image/test_dither_algorithms.py @@ -0,0 +1,363 @@ +"""Tests for the error-diffusion and screen dithering algorithms.""" + +from typing import cast + +import numpy as np +import pytest +from raygeo.image.dither import ( + apply_floyd_steinberg_dither, + apply_minimum_run_length, +) +from scipy import ndimage + +from rayforge.image.dither import ( + ERROR_DIFFUSION_KERNELS, + DitherAlgorithm, + apply_error_diffusion, + apply_halftone, + grayscale_to_dithered_array, + srgb_to_linear, +) + +ERROR_DIFFUSION = [ + DitherAlgorithm.FLOYD_STEINBERG, + DitherAlgorithm.ATKINSON, + DitherAlgorithm.STUCKI, + DitherAlgorithm.JARVIS_JUDICE_NINKE, + DitherAlgorithm.SIERRA, + DitherAlgorithm.SIERRA_2ROW, + DitherAlgorithm.SIERRA_LITE, + DitherAlgorithm.BURKES, +] + +BAYER = [ + DitherAlgorithm.BAYER2, + DitherAlgorithm.BAYER4, + DitherAlgorithm.BAYER8, +] + +NEW_ALGORITHMS = [ + a + for a in DitherAlgorithm + if a is not DitherAlgorithm.FLOYD_STEINBERG and a not in BAYER +] + + +def _reference_diffusion(values, kernel, serpentine): + """Straightforward per-pixel error diffusion used as an oracle.""" + divisor, taps = kernel + work = values.astype(np.float64).copy() + height, width = work.shape + out = np.zeros((height, width), dtype=np.uint8) + for y in range(height): + reverse = serpentine and y % 2 == 1 + xs = range(width - 1, -1, -1) if reverse else range(width) + for x in xs: + old = work[y, x] + new = 0.0 if old < 0.5 else 1.0 + out[y, x] = 1 if new == 0.0 else 0 + err = old - new + for dx, dy, weight in taps: + tx = x - dx if reverse else x + dx + ty = y + dy + if 0 <= tx < width and ty < height: + work[ty, tx] += err * weight / divisor + return out + + +def _label(image: np.ndarray) -> tuple[np.ndarray, int]: + """Connected dots of a binary image and their count.""" + return cast(tuple[np.ndarray, int], ndimage.label(image)) + + +def _dyadic_image(rng, shape): + """Values k/16 keep every diffused error exactly representable.""" + return rng.integers(0, 17, shape).astype(np.float64) / 16.0 + + +class TestAlgorithmCatalogue: + def test_new_algorithms_exist_with_stable_values(self): + assert DitherAlgorithm.ATKINSON.value == "atkinson" + assert DitherAlgorithm.STUCKI.value == "stucki" + assert DitherAlgorithm.JARVIS_JUDICE_NINKE.value == "jarvis" + assert DitherAlgorithm.SIERRA.value == "sierra" + assert DitherAlgorithm.SIERRA_2ROW.value == "sierra_2row" + assert DitherAlgorithm.SIERRA_LITE.value == "sierra_lite" + assert DitherAlgorithm.BURKES.value == "burkes" + assert DitherAlgorithm.NEWSPRINT.value == "newsprint" + assert DitherAlgorithm.HALFTONE.value == "halftone" + + def test_every_algorithm_has_a_display_name(self): + for algo in DitherAlgorithm: + assert algo.display_name + + def test_error_diffusion_flag(self): + for algo in DitherAlgorithm: + assert algo.is_error_diffusion == (algo in ERROR_DIFFUSION) + + @pytest.mark.parametrize("algo", ERROR_DIFFUSION) + def test_kernel_weights_sum_to_divisor(self, algo): + divisor, taps = ERROR_DIFFUSION_KERNELS[algo] + total = sum(weight for _dx, _dy, weight in taps) + if algo is DitherAlgorithm.ATKINSON: + assert total == 6 and divisor == 8 + else: + assert total == divisor + + @pytest.mark.parametrize("algo", ERROR_DIFFUSION) + def test_kernels_only_push_error_forward(self, algo): + _divisor, taps = ERROR_DIFFUSION_KERNELS[algo] + for dx, dy, _weight in taps: + assert dy > 0 or (dy == 0 and dx > 0) + + def test_stucki_kernel(self): + divisor, taps = ERROR_DIFFUSION_KERNELS[DitherAlgorithm.STUCKI] + assert divisor == 42 + assert sorted(taps) == sorted( + [ + (1, 0, 8), + (2, 0, 4), + (-2, 1, 2), + (-1, 1, 4), + (0, 1, 8), + (1, 1, 4), + (2, 1, 2), + (-2, 2, 1), + (-1, 2, 2), + (0, 2, 4), + (1, 2, 2), + (2, 2, 1), + ] + ) + + +class TestErrorDiffusionCore: + @pytest.mark.parametrize("algo", ERROR_DIFFUSION) + @pytest.mark.parametrize("serpentine", [False, True]) + def test_matches_reference_implementation(self, algo, serpentine): + rng = np.random.default_rng(7) + kernel = ERROR_DIFFUSION_KERNELS[algo] + for shape in [(1, 1), (1, 6), (5, 1), (4, 6), (6, 5)]: + values = _dyadic_image(rng, shape) + expected = _reference_diffusion(values, kernel, serpentine) + result = apply_error_diffusion(values, kernel, serpentine) + np.testing.assert_array_equal(result, expected) + + def test_serpentine_reverses_odd_rows(self): + kernel = ERROR_DIFFUSION_KERNELS[DitherAlgorithm.SIERRA_LITE] + values = np.tile(np.linspace(0.0, 1.0, 8), (4, 1)) + plain = apply_error_diffusion(values, kernel, False) + serpentine = apply_error_diffusion(values, kernel, True) + np.testing.assert_array_equal(plain[:2], serpentine[:2]) + assert not np.array_equal(plain, serpentine) + + def test_does_not_modify_input(self): + kernel = ERROR_DIFFUSION_KERNELS[DitherAlgorithm.STUCKI] + values = np.full((5, 5), 0.3) + original = values.copy() + apply_error_diffusion(values, kernel, True) + np.testing.assert_array_equal(values, original) + + def test_empty_image(self): + kernel = ERROR_DIFFUSION_KERNELS[DitherAlgorithm.STUCKI] + result = apply_error_diffusion(np.zeros((0, 4)), kernel, False) + assert result.shape == (0, 4) + + +class TestGrayscaleToDitheredArray: + @pytest.mark.parametrize("algo", NEW_ALGORITHMS) + def test_white_is_never_engraved(self, algo): + white = np.full((24, 24), 255, dtype=np.uint8) + result = grayscale_to_dithered_array(white, algo) + assert result.dtype == np.uint8 + assert result.shape == white.shape + assert not result.any() + + @pytest.mark.parametrize("algo", NEW_ALGORITHMS) + def test_black_is_fully_engraved(self, algo): + black = np.zeros((24, 24), dtype=np.uint8) + result = grayscale_to_dithered_array(black, algo) + assert result.all() + + @pytest.mark.parametrize("algo", NEW_ALGORITHMS) + def test_output_is_binary_and_deterministic(self, algo): + rng = np.random.default_rng(3) + img = rng.integers(0, 256, (31, 47)).astype(np.uint8) + first = grayscale_to_dithered_array(img, algo) + second = grayscale_to_dithered_array(img.copy(), algo) + assert set(np.unique(first)) <= {0, 1} + np.testing.assert_array_equal(first, second) + + @pytest.mark.parametrize("algo", NEW_ALGORITHMS) + def test_invert_engraves_the_complement(self, algo): + rng = np.random.default_rng(5) + img = rng.integers(0, 256, (20, 30)).astype(np.uint8) + inverted = grayscale_to_dithered_array(img, algo, invert=True) + expected = grayscale_to_dithered_array(255 - img, algo) + np.testing.assert_array_equal(inverted, expected) + + @pytest.mark.parametrize( + "algo", + [a for a in ERROR_DIFFUSION if a is not DitherAlgorithm.ATKINSON], + ) + @pytest.mark.parametrize("gray", [40, 128, 200]) + def test_error_diffusion_preserves_linear_tone(self, algo, gray): + img = np.full((64, 96), gray, dtype=np.uint8) + result = grayscale_to_dithered_array(img, algo) + expected = 1.0 - float( + srgb_to_linear(np.array([gray], dtype=np.uint8))[0] + ) + assert result.mean() == pytest.approx(expected, abs=0.02) + + def test_atkinson_loses_a_quarter_of_the_error(self): + """Atkinson only diffuses 6/8 of the error, which clips dark + and light tones and raises contrast compared to FS.""" + img = np.full((64, 64), 230, dtype=np.uint8) + atkinson = grayscale_to_dithered_array(img, DitherAlgorithm.ATKINSON) + stucki = grayscale_to_dithered_array(img, DitherAlgorithm.STUCKI) + assert atkinson.mean() < stucki.mean() + + def test_floyd_steinberg_without_serpentine_keeps_raygeo(self): + rng = np.random.default_rng(11) + img = rng.integers(0, 256, (40, 60)).astype(np.uint8) + result = grayscale_to_dithered_array( + img, DitherAlgorithm.FLOYD_STEINBERG, min_feature_px=2 + ) + expected = apply_minimum_run_length( + apply_floyd_steinberg_dither(img, False), 2 + ) + np.testing.assert_array_equal(result, expected) + + def test_floyd_steinberg_serpentine_differs_from_raygeo(self): + img = np.tile(np.linspace(0, 255, 64).astype(np.uint8), (32, 1)) + plain = grayscale_to_dithered_array( + img, DitherAlgorithm.FLOYD_STEINBERG + ) + serpentine = grayscale_to_dithered_array( + img, DitherAlgorithm.FLOYD_STEINBERG, serpentine=True + ) + assert not np.array_equal(plain, serpentine) + assert serpentine.mean() == pytest.approx(plain.mean(), abs=0.02) + + @pytest.mark.parametrize("algo", ERROR_DIFFUSION[1:]) + def test_minimum_feature_size_removes_short_runs(self, algo): + img = np.full((32, 64), 200, dtype=np.uint8) + result = grayscale_to_dithered_array(img, algo, min_feature_px=3) + for row in result: + padded = np.concatenate(([0], row, [0])) + edges = np.flatnonzero(np.diff(padded)) + runs = edges[1::2] - edges[::2] + assert (runs >= 3).all() + + def test_serpentine_is_ignored_by_screens(self): + img = np.tile(np.linspace(0, 255, 64).astype(np.uint8), (32, 1)) + for algo in (DitherAlgorithm.BAYER4, DitherAlgorithm.NEWSPRINT): + np.testing.assert_array_equal( + grayscale_to_dithered_array(img, algo), + grayscale_to_dithered_array(img, algo, serpentine=True), + ) + + +class TestHalftone: + @pytest.mark.parametrize("gray", [30, 100, 160, 220]) + def test_coverage_follows_darkness(self, gray): + img = np.full((200, 200), gray, dtype=np.uint8) + result = apply_halftone( + img, cell_mm=1.0, angle_deg=45.0, pixels_per_mm=(10.0, 10.0) + ) + assert result.mean() == pytest.approx(1.0 - gray / 255.0, abs=0.03) + + def test_dot_count_follows_cell_size(self): + """At 0° the dot centres sit on whole cells from the origin, so + a 20 mm square holds 21 x 21 (partly clipped) dots at 1 mm.""" + img = np.full((200, 200), 230, dtype=np.uint8) + for cell_mm, expected in ((1.0, 441), (2.0, 121)): + result = apply_halftone( + img, + cell_mm=cell_mm, + angle_deg=0.0, + pixels_per_mm=(10.0, 10.0), + ) + _labels, count = _label(result) + assert count == pytest.approx(expected, rel=0.1) + + def test_dots_are_round_on_anisotropic_pixels(self): + """X is sampled twice as densely as Y; the dots must still be + round in millimetres, so twice as wide in pixels.""" + img = np.full((100, 200), 220, dtype=np.uint8) + result = apply_halftone( + img, cell_mm=2.0, angle_deg=0.0, pixels_per_mm=(20.0, 10.0) + ) + labels, _count = _label(result) + boxes = ndimage.find_objects(labels) + interior = [ + b + for b in boxes + if b[0].start > 0 + and b[1].start > 0 + and b[0].stop < img.shape[0] + and b[1].stop < img.shape[1] + ] + assert interior + for rows, cols in interior: + height = rows.stop - rows.start + width = cols.stop - cols.start + assert width == pytest.approx(2 * height, abs=2) + + def test_angle_rotates_the_screen(self): + img = np.full((120, 120), 200, dtype=np.uint8) + kwargs = {"cell_mm": 1.0, "pixels_per_mm": (10.0, 10.0)} + straight = apply_halftone(img, angle_deg=0.0, **kwargs) + rotated = apply_halftone(img, angle_deg=45.0, **kwargs) + assert not np.array_equal(straight, rotated) + assert rotated.mean() == pytest.approx(straight.mean(), abs=0.03) + + def test_invalid_cell_size_falls_back_to_one_pixel_cells(self): + img = np.full((10, 10), 0, dtype=np.uint8) + result = apply_halftone( + img, cell_mm=0.0, angle_deg=0.0, pixels_per_mm=(10.0, 10.0) + ) + assert result.all() + + def test_dispatch_through_grayscale_to_dithered_array(self): + img = np.full((100, 100), 128, dtype=np.uint8) + direct = apply_halftone( + img, cell_mm=0.8, angle_deg=30.0, pixels_per_mm=(10.0, 10.0) + ) + dispatched = grayscale_to_dithered_array( + img, + DitherAlgorithm.HALFTONE, + halftone_cell_mm=0.8, + halftone_angle=30.0, + pixels_per_mm=(10.0, 10.0), + ) + np.testing.assert_array_equal(direct, dispatched) + + +class TestNewsprint: + def test_coverage_follows_darkness(self): + img = np.full((64, 64), 128, dtype=np.uint8) + result = grayscale_to_dithered_array(img, DitherAlgorithm.NEWSPRINT) + assert result.mean() == pytest.approx(1.0 - 128 / 255.0, abs=0.03) + + def test_dots_are_clustered(self): + """A clustered-dot screen forms far fewer separate dots than a + dispersed (Bayer) screen at the same tone.""" + img = np.full((64, 64), 200, dtype=np.uint8) + news = grayscale_to_dithered_array(img, DitherAlgorithm.NEWSPRINT) + bayer = grayscale_to_dithered_array(img, DitherAlgorithm.BAYER8) + _l1, news_dots = _label(news) + _l2, bayer_dots = _label(bayer) + assert news_dots < bayer_dots / 2 + + def test_cell_follows_minimum_feature_size(self): + img = np.full((64, 64), 200, dtype=np.uint8) + fine = grayscale_to_dithered_array( + img, DitherAlgorithm.NEWSPRINT, min_feature_px=1 + ) + coarse = grayscale_to_dithered_array( + img, DitherAlgorithm.NEWSPRINT, min_feature_px=2 + ) + _l1, fine_dots = _label(fine) + _l2, coarse_dots = _label(coarse) + assert coarse_dots < fine_dots From ebe11ac1538e52890cdc2529240abd5432548408 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Fri, 9 Oct 2026 12:47:04 +0200 Subject: [PATCH 3/6] feat(engrave): expose serpentine and halftone settings The Engrave step's dither algorithm list now offers every DitherAlgorithm. New step settings, saved with the document, recipes and cache params: - Serpentine: alternate the scan direction per row, shown for the error-diffusion algorithms. - Halftone Cell Size and Halftone Angle, shown for Halftone. preprocess_raster_image() forwards them together with the Y resolution, so the screens can keep their cells square in millimetres. apply_import_settings() accepts the depth mode, threshold and dither settings so importers can set them. Refs #514 --- .../laser_essentials/steps/raster_step.py | 81 ++++++++++- .../laser_essentials/widgets/raster_page.py | 3 + .../locale/de/LC_MESSAGES/laser_essentials.po | 24 +++ .../locale/en/LC_MESSAGES/laser_essentials.po | 24 +++ .../locale/es/LC_MESSAGES/laser_essentials.po | 24 +++ .../locale/fr/LC_MESSAGES/laser_essentials.po | 24 +++ .../locale/hi/LC_MESSAGES/laser_essentials.po | 24 +++ .../locale/laser_essentials.pot | 24 +++ .../locale/pt/LC_MESSAGES/laser_essentials.po | 24 +++ .../locale/uk/LC_MESSAGES/laser_essentials.po | 24 +++ .../zh_CN/LC_MESSAGES/laser_essentials.po | 24 +++ .../tests/steps/test_raster_step.py | 137 ++++++++++++++++++ .../tests/ui_gtk/test_laser_pages.py | 57 +++++++- rayforge/pipeline/stage/assembler_helpers.py | 23 ++- .../pipeline/stage/test_assembler_helpers.py | 73 ++++++++++ 15 files changed, 587 insertions(+), 3 deletions(-) diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/steps/raster_step.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/steps/raster_step.py index 2c028cbb6..1e1fced13 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/steps/raster_step.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/steps/raster_step.py @@ -27,7 +27,11 @@ SliderIntVar, VarSet, ) -from rayforge.image.dither import DitherAlgorithm +from rayforge.image.dither import ( + DEFAULT_HALFTONE_ANGLE, + DEFAULT_HALFTONE_CELL_MM, + DitherAlgorithm, +) from rayforge.machine.models.laser import LaserHead from rayforge.pipeline.stage.assembler_helpers import ( DepthMode, @@ -76,6 +80,22 @@ def is_dither(v): def is_multi_pass(v): return v.get("depth_mode") == "MULTI_PASS" + def dither_algorithm(v): + name = v.get("dither_algorithm") or "FLOYD_STEINBERG" + return DitherAlgorithm.__members__.get(name) + + def is_error_diffusion(v): + algo = dither_algorithm(v) + return ( + is_dither(v) and algo is not None and algo.is_error_diffusion + ) + + def is_halftone(v): + return ( + is_dither(v) + and dither_algorithm(v) is DitherAlgorithm.HALFTONE + ) + def uses_grayscale(v): return is_power(v) or is_multi_pass(v) @@ -114,6 +134,35 @@ def uses_levels(v): visible_when=is_dither, allow_none=False, ), + BoolVar( + key="dither_serpentine", + label=_("Serpentine"), + description=_( + "Alternate the dither direction on every row to " + "avoid directional artifacts" + ), + default=False, + visible_when=is_error_diffusion, + ), + LengthVar( + key="halftone_cell_mm", + label=_("Halftone Cell Size"), + description=_("Distance between halftone dot centers"), + default=DEFAULT_HALFTONE_CELL_MM, + min_val=0.01, + max_val=50.0, + digits=3, + visible_when=is_halftone, + ), + AngleVar( + key="halftone_angle", + label=_("Halftone Angle"), + description=_("Rotation of the halftone dot grid"), + default=DEFAULT_HALFTONE_ANGLE, + min_val=0.0, + max_val=180.0, + visible_when=is_halftone, + ), ScanAngleVar(), BoolVar( key="cross_hatch", @@ -305,6 +354,9 @@ def __init__(self, name: str | None = None, typelabel: str | None = None): self.z_step_down = 0.0 self.angle_increment = 0.0 self.dither_algorithm = None + self.dither_serpentine = False + self.halftone_cell_mm = DEFAULT_HALFTONE_CELL_MM + self.halftone_angle = DEFAULT_HALFTONE_ANGLE self.bidir_x_offset_mm = 0.0 def set_dither_algorithm(self, algorithm: DitherAlgorithm | str | None): @@ -394,9 +446,16 @@ def apply_import_settings(self, settings: dict[str, Any]) -> None: "dot_width_correction_mm", "line_interval_mm", "scan_angle", + "depth_mode", + "threshold", + "dither_serpentine", + "halftone_cell_mm", + "halftone_angle", ): if key in settings: setattr(self, key, settings[key]) + if "dither_algorithm" in settings: + self.set_dither_algorithm(settings["dither_algorithm"]) def build_compute_payload( self, @@ -481,6 +540,9 @@ def get_cache_params(self) -> dict: if self.dither_algorithm is not None else None ), + "dither_serpentine": self.dither_serpentine, + "halftone_cell_mm": self.halftone_cell_mm, + "halftone_angle": self.halftone_angle, } ) return params @@ -510,6 +572,9 @@ def to_dict(self) -> dict: result["dither_algorithm"] = ( self.dither_algorithm.value if self.dither_algorithm else None ) + result["dither_serpentine"] = self.dither_serpentine + result["halftone_cell_mm"] = self.halftone_cell_mm + result["halftone_angle"] = self.halftone_angle result["bidir_x_offset_mm"] = self.bidir_x_offset_mm return result @@ -600,6 +665,13 @@ def from_dict(cls, data: dict) -> EngraveStep: step.dither_algorithm = DitherAlgorithm(dither_val) except ValueError: step.dither_algorithm = DitherAlgorithm.FLOYD_STEINBERG + step.dither_serpentine = data.get("dither_serpentine", False) + step.halftone_cell_mm = data.get( + "halftone_cell_mm", DEFAULT_HALFTONE_CELL_MM + ) + step.halftone_angle = data.get( + "halftone_angle", DEFAULT_HALFTONE_ANGLE + ) step.bidir_x_offset_mm = data.get("bidir_x_offset_mm", 0.0) return step @@ -628,6 +700,9 @@ def _serialized_keys(cls) -> frozenset[str]: "z_step_down", "angle_increment", "dither_algorithm", + "dither_serpentine", + "halftone_cell_mm", + "halftone_angle", "bidir_x_offset_mm", "min_power", "max_power", @@ -771,6 +846,10 @@ def _build_raster_part( dither_algorithm=step.dither_algorithm, laser_spot_x_mm=spot_x, pixels_per_mm_x=px_per_mm_x, + pixels_per_mm_y=px_per_mm_y, + dither_serpentine=step.dither_serpentine, + halftone_cell_mm=step.halftone_cell_mm, + halftone_angle=step.halftone_angle, ) surface.flush() if image is None: diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/raster_page.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/raster_page.py index 330381376..124671f8a 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/raster_page.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/raster_page.py @@ -19,6 +19,9 @@ "depth_mode", "threshold", "dither_algorithm", + "dither_serpentine", + "halftone_cell_mm", + "halftone_angle", "scan_angle", "cross_hatch", "scan_mode", diff --git a/rayforge/builtin_addons/rayforge-addon-laser/locale/de/LC_MESSAGES/laser_essentials.po b/rayforge/builtin_addons/rayforge-addon-laser/locale/de/LC_MESSAGES/laser_essentials.po index c0a678a26..177c113ed 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/locale/de/LC_MESSAGES/laser_essentials.po +++ b/rayforge/builtin_addons/rayforge-addon-laser/locale/de/LC_MESSAGES/laser_essentials.po @@ -802,3 +802,27 @@ msgstr "Laserkopf" #: laser_essentials/commands/material_test_cmd.py msgid "Add Material Test" msgstr "Materialtest hinzufügen" + +#: laser_essentials/steps/raster_step.py +msgid "Serpentine" +msgstr "Serpentinenmodus" + +#: laser_essentials/steps/raster_step.py +msgid "Alternate the dither direction on every row to avoid directional artifacts" +msgstr "Ditherrichtung in jeder Zeile wechseln, um richtungsabhängige Artefakte zu vermeiden" + +#: laser_essentials/steps/raster_step.py +msgid "Halftone Cell Size" +msgstr "Halbton-Zellgröße" + +#: laser_essentials/steps/raster_step.py +msgid "Distance between halftone dot centers" +msgstr "Abstand zwischen den Mittelpunkten der Halbtonpunkte" + +#: laser_essentials/steps/raster_step.py +msgid "Halftone Angle" +msgstr "Halbton-Winkel" + +#: laser_essentials/steps/raster_step.py +msgid "Rotation of the halftone dot grid" +msgstr "Drehung des Halbton-Punktrasters" diff --git a/rayforge/builtin_addons/rayforge-addon-laser/locale/en/LC_MESSAGES/laser_essentials.po b/rayforge/builtin_addons/rayforge-addon-laser/locale/en/LC_MESSAGES/laser_essentials.po index 928a924cc..aa82df27d 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/locale/en/LC_MESSAGES/laser_essentials.po +++ b/rayforge/builtin_addons/rayforge-addon-laser/locale/en/LC_MESSAGES/laser_essentials.po @@ -770,3 +770,27 @@ msgstr "" #: laser_essentials/commands/material_test_cmd.py msgid "Add Material Test" msgstr "Add Material Test" + +#: laser_essentials/steps/raster_step.py +msgid "Serpentine" +msgstr "" + +#: laser_essentials/steps/raster_step.py +msgid "Alternate the dither direction on every row to avoid directional artifacts" +msgstr "" + +#: laser_essentials/steps/raster_step.py +msgid "Halftone Cell Size" +msgstr "" + +#: laser_essentials/steps/raster_step.py +msgid "Distance between halftone dot centers" +msgstr "" + +#: laser_essentials/steps/raster_step.py +msgid "Halftone Angle" +msgstr "" + +#: laser_essentials/steps/raster_step.py +msgid "Rotation of the halftone dot grid" +msgstr "" diff --git a/rayforge/builtin_addons/rayforge-addon-laser/locale/es/LC_MESSAGES/laser_essentials.po b/rayforge/builtin_addons/rayforge-addon-laser/locale/es/LC_MESSAGES/laser_essentials.po index 0cdf551f7..ad2cc4de2 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/locale/es/LC_MESSAGES/laser_essentials.po +++ b/rayforge/builtin_addons/rayforge-addon-laser/locale/es/LC_MESSAGES/laser_essentials.po @@ -806,3 +806,27 @@ msgstr "Cabezal láser" #: laser_essentials/commands/material_test_cmd.py msgid "Add Material Test" msgstr "Añadir prueba de material" + +#: laser_essentials/steps/raster_step.py +msgid "Serpentine" +msgstr "Serpentina" + +#: laser_essentials/steps/raster_step.py +msgid "Alternate the dither direction on every row to avoid directional artifacts" +msgstr "Alternar la dirección del difuminado en cada fila para evitar artefactos direccionales" + +#: laser_essentials/steps/raster_step.py +msgid "Halftone Cell Size" +msgstr "Tamaño de celda del semitono" + +#: laser_essentials/steps/raster_step.py +msgid "Distance between halftone dot centers" +msgstr "Distancia entre los centros de los puntos del semitono" + +#: laser_essentials/steps/raster_step.py +msgid "Halftone Angle" +msgstr "Ángulo del semitono" + +#: laser_essentials/steps/raster_step.py +msgid "Rotation of the halftone dot grid" +msgstr "Rotación de la cuadrícula de puntos del semitono" diff --git a/rayforge/builtin_addons/rayforge-addon-laser/locale/fr/LC_MESSAGES/laser_essentials.po b/rayforge/builtin_addons/rayforge-addon-laser/locale/fr/LC_MESSAGES/laser_essentials.po index cdf300358..1ea71b7c2 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/locale/fr/LC_MESSAGES/laser_essentials.po +++ b/rayforge/builtin_addons/rayforge-addon-laser/locale/fr/LC_MESSAGES/laser_essentials.po @@ -803,3 +803,27 @@ msgstr "Tête laser" #: laser_essentials/commands/material_test_cmd.py msgid "Add Material Test" msgstr "Ajouter un test de matériau" + +#: laser_essentials/steps/raster_step.py +msgid "Serpentine" +msgstr "Serpentin" + +#: laser_essentials/steps/raster_step.py +msgid "Alternate the dither direction on every row to avoid directional artifacts" +msgstr "Alterner le sens du tramage à chaque ligne pour éviter les artefacts directionnels" + +#: laser_essentials/steps/raster_step.py +msgid "Halftone Cell Size" +msgstr "Taille de cellule de demi-teinte" + +#: laser_essentials/steps/raster_step.py +msgid "Distance between halftone dot centers" +msgstr "Distance entre les centres des points de demi-teinte" + +#: laser_essentials/steps/raster_step.py +msgid "Halftone Angle" +msgstr "Angle de demi-teinte" + +#: laser_essentials/steps/raster_step.py +msgid "Rotation of the halftone dot grid" +msgstr "Rotation de la grille de points de demi-teinte" diff --git a/rayforge/builtin_addons/rayforge-addon-laser/locale/hi/LC_MESSAGES/laser_essentials.po b/rayforge/builtin_addons/rayforge-addon-laser/locale/hi/LC_MESSAGES/laser_essentials.po index 9566fab10..c935dfdd1 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/locale/hi/LC_MESSAGES/laser_essentials.po +++ b/rayforge/builtin_addons/rayforge-addon-laser/locale/hi/LC_MESSAGES/laser_essentials.po @@ -784,3 +784,27 @@ msgstr "लेज़र हेड" #: laser_essentials/commands/material_test_cmd.py msgid "Add Material Test" msgstr "सामग्री परीक्षण जोड़ें" + +#: laser_essentials/steps/raster_step.py +msgid "Serpentine" +msgstr "सर्पाकार" + +#: laser_essentials/steps/raster_step.py +msgid "Alternate the dither direction on every row to avoid directional artifacts" +msgstr "दिशात्मक दोषों से बचने के लिए हर पंक्ति में डाइदर की दिशा बदलें" + +#: laser_essentials/steps/raster_step.py +msgid "Halftone Cell Size" +msgstr "हाफ़टोन सेल आकार" + +#: laser_essentials/steps/raster_step.py +msgid "Distance between halftone dot centers" +msgstr "हाफ़टोन बिंदुओं के केंद्रों के बीच की दूरी" + +#: laser_essentials/steps/raster_step.py +msgid "Halftone Angle" +msgstr "हाफ़टोन कोण" + +#: laser_essentials/steps/raster_step.py +msgid "Rotation of the halftone dot grid" +msgstr "हाफ़टोन बिंदु ग्रिड का घुमाव" diff --git a/rayforge/builtin_addons/rayforge-addon-laser/locale/laser_essentials.pot b/rayforge/builtin_addons/rayforge-addon-laser/locale/laser_essentials.pot index 78f3aff98..a312c2959 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/locale/laser_essentials.pot +++ b/rayforge/builtin_addons/rayforge-addon-laser/locale/laser_essentials.pot @@ -774,3 +774,27 @@ msgstr "" #: laser_essentials/commands/material_test_cmd.py msgid "Add Material Test" msgstr "" + +#: laser_essentials/steps/raster_step.py +msgid "Serpentine" +msgstr "" + +#: laser_essentials/steps/raster_step.py +msgid "Alternate the dither direction on every row to avoid directional artifacts" +msgstr "" + +#: laser_essentials/steps/raster_step.py +msgid "Halftone Cell Size" +msgstr "" + +#: laser_essentials/steps/raster_step.py +msgid "Distance between halftone dot centers" +msgstr "" + +#: laser_essentials/steps/raster_step.py +msgid "Halftone Angle" +msgstr "" + +#: laser_essentials/steps/raster_step.py +msgid "Rotation of the halftone dot grid" +msgstr "" diff --git a/rayforge/builtin_addons/rayforge-addon-laser/locale/pt/LC_MESSAGES/laser_essentials.po b/rayforge/builtin_addons/rayforge-addon-laser/locale/pt/LC_MESSAGES/laser_essentials.po index 19076e5d4..99d712321 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/locale/pt/LC_MESSAGES/laser_essentials.po +++ b/rayforge/builtin_addons/rayforge-addon-laser/locale/pt/LC_MESSAGES/laser_essentials.po @@ -799,3 +799,27 @@ msgstr "Cabeça de Laser" #: laser_essentials/commands/material_test_cmd.py msgid "Add Material Test" msgstr "Adicionar Teste de Material" + +#: laser_essentials/steps/raster_step.py +msgid "Serpentine" +msgstr "Serpentina" + +#: laser_essentials/steps/raster_step.py +msgid "Alternate the dither direction on every row to avoid directional artifacts" +msgstr "Alternar a direção do pontilhado em cada linha para evitar artefatos direcionais" + +#: laser_essentials/steps/raster_step.py +msgid "Halftone Cell Size" +msgstr "Tamanho da célula de meio-tom" + +#: laser_essentials/steps/raster_step.py +msgid "Distance between halftone dot centers" +msgstr "Distância entre os centros dos pontos de meio-tom" + +#: laser_essentials/steps/raster_step.py +msgid "Halftone Angle" +msgstr "Ângulo do meio-tom" + +#: laser_essentials/steps/raster_step.py +msgid "Rotation of the halftone dot grid" +msgstr "Rotação da grade de pontos de meio-tom" diff --git a/rayforge/builtin_addons/rayforge-addon-laser/locale/uk/LC_MESSAGES/laser_essentials.po b/rayforge/builtin_addons/rayforge-addon-laser/locale/uk/LC_MESSAGES/laser_essentials.po index 9360570dd..4b80f351f 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/locale/uk/LC_MESSAGES/laser_essentials.po +++ b/rayforge/builtin_addons/rayforge-addon-laser/locale/uk/LC_MESSAGES/laser_essentials.po @@ -797,3 +797,27 @@ msgstr "Лазерна голівка" #: laser_essentials/commands/material_test_cmd.py msgid "Add Material Test" msgstr "Додати тест матеріалу" + +#: laser_essentials/steps/raster_step.py +msgid "Serpentine" +msgstr "Змійкою" + +#: laser_essentials/steps/raster_step.py +msgid "Alternate the dither direction on every row to avoid directional artifacts" +msgstr "Змінювати напрямок дизерингу в кожному рядку, щоб уникнути спрямованих артефактів" + +#: laser_essentials/steps/raster_step.py +msgid "Halftone Cell Size" +msgstr "Розмір комірки півтону" + +#: laser_essentials/steps/raster_step.py +msgid "Distance between halftone dot centers" +msgstr "Відстань між центрами півтонових точок" + +#: laser_essentials/steps/raster_step.py +msgid "Halftone Angle" +msgstr "Кут півтону" + +#: laser_essentials/steps/raster_step.py +msgid "Rotation of the halftone dot grid" +msgstr "Поворот сітки півтонових точок" diff --git a/rayforge/builtin_addons/rayforge-addon-laser/locale/zh_CN/LC_MESSAGES/laser_essentials.po b/rayforge/builtin_addons/rayforge-addon-laser/locale/zh_CN/LC_MESSAGES/laser_essentials.po index d41a2a5df..02c088af7 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/locale/zh_CN/LC_MESSAGES/laser_essentials.po +++ b/rayforge/builtin_addons/rayforge-addon-laser/locale/zh_CN/LC_MESSAGES/laser_essentials.po @@ -776,3 +776,27 @@ msgstr "激光头" #: laser_essentials/commands/material_test_cmd.py msgid "Add Material Test" msgstr "添加材料测试" + +#: laser_essentials/steps/raster_step.py +msgid "Serpentine" +msgstr "蛇形扫描" + +#: laser_essentials/steps/raster_step.py +msgid "Alternate the dither direction on every row to avoid directional artifacts" +msgstr "每行交替抖动方向,以避免方向性伪影" + +#: laser_essentials/steps/raster_step.py +msgid "Halftone Cell Size" +msgstr "半色调网格尺寸" + +#: laser_essentials/steps/raster_step.py +msgid "Distance between halftone dot centers" +msgstr "半色调网点中心之间的距离" + +#: laser_essentials/steps/raster_step.py +msgid "Halftone Angle" +msgstr "半色调角度" + +#: laser_essentials/steps/raster_step.py +msgid "Rotation of the halftone dot grid" +msgstr "半色调网点网格的旋转角度" diff --git a/rayforge/builtin_addons/rayforge-addon-laser/tests/steps/test_raster_step.py b/rayforge/builtin_addons/rayforge-addon-laser/tests/steps/test_raster_step.py index 7875f4c7b..8c7591621 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/tests/steps/test_raster_step.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/tests/steps/test_raster_step.py @@ -11,6 +11,7 @@ from raygeo.ops.types import CommandCategory, CommandType from rayforge.core.step_registry import step_registry +from rayforge.core.varset import LabeledChoiceVar from rayforge.core.workpiece import WorkPiece from rayforge.image.dither import DitherAlgorithm from rayforge.pipeline.transformer import OpsTransformer @@ -543,3 +544,139 @@ def test_enabled_crop_keeps_arc_inside_stock(self, mock_context): assert ops.endpoint(arcs[0]) == pytest.approx( (0.6, 0.5, 0.0), abs=1e-6 ) + + +class TestEngraveDitherOptions: + """Serpentine and halftone settings of the Dither mode.""" + + def _var(self, key): + varset = EngraveStep.recipe_varset() + return next(v for v in varset.vars if v.key == key) + + def test_defaults(self): + step = EngraveStep(name="engrave") + assert step.dither_serpentine is False + assert step.halftone_cell_mm == 0.5 + assert step.halftone_angle == 45.0 + + def test_roundtrip_serialization(self): + step = EngraveStep(name="engrave") + step.depth_mode = "DITHER" + step.dither_algorithm = DitherAlgorithm.HALFTONE + step.dither_serpentine = True + step.halftone_cell_mm = 0.8 + step.halftone_angle = 15.0 + data = step.to_dict() + restored = EngraveStep.from_dict(data) + assert restored.dither_algorithm is DitherAlgorithm.HALFTONE + assert restored.dither_serpentine is True + assert restored.halftone_cell_mm == 0.8 + assert restored.halftone_angle == 15.0 + assert data == restored.to_dict() + + def test_old_documents_get_defaults(self): + data = EngraveStep(name="engrave").to_dict() + for key in ("dither_serpentine", "halftone_cell_mm", "halftone_angle"): + data.pop(key) + restored = EngraveStep.from_dict(data) + assert restored.dither_serpentine is False + assert restored.halftone_cell_mm == 0.5 + assert restored.halftone_angle == 45.0 + + def test_new_algorithms_load_by_value(self): + data = EngraveStep(name="engrave").to_dict() + data["dither_algorithm"] = "stucki" + restored = EngraveStep.from_dict(data) + assert restored.dither_algorithm is DitherAlgorithm.STUCKI + + def test_cache_params_cover_the_new_settings(self): + step = EngraveStep(name="engrave") + before = step.get_cache_params() + for attr, value in ( + ("dither_serpentine", True), + ("halftone_cell_mm", 1.2), + ("halftone_angle", 10.0), + ): + setattr(step, attr, value) + after = step.get_cache_params() + assert after[attr] == value + assert after != before + before = after + + def test_varset_lists_every_algorithm(self): + var = self._var("dither_algorithm") + assert isinstance(var, LabeledChoiceVar) + names = [var.get_value_for_display(label) for label in var.choices] + assert names == [a.name for a in DitherAlgorithm] + + @pytest.mark.parametrize( + "algo, visible", + [ + ("FLOYD_STEINBERG", True), + ("STUCKI", True), + ("ATKINSON", True), + ("BAYER4", False), + ("NEWSPRINT", False), + ("HALFTONE", False), + ], + ) + def test_serpentine_only_for_error_diffusion(self, algo, visible): + var = self._var("dither_serpentine") + values = {"depth_mode": "DITHER", "dither_algorithm": algo} + assert var.is_visible(values) is visible + values["depth_mode"] = "POWER_MODULATION" + assert var.is_visible(values) is False + + @pytest.mark.parametrize("key", ["halftone_cell_mm", "halftone_angle"]) + def test_halftone_rows_only_for_halftone(self, key): + var = self._var(key) + assert var.is_visible( + {"depth_mode": "DITHER", "dither_algorithm": "HALFTONE"} + ) + assert not var.is_visible( + {"depth_mode": "DITHER", "dither_algorithm": "STUCKI"} + ) + assert not var.is_visible( + {"depth_mode": "CONSTANT_POWER", "dither_algorithm": "HALFTONE"} + ) + + def test_apply_import_settings_sets_dither_mode(self): + step = EngraveStep(name="engrave") + step.apply_import_settings( + { + "depth_mode": "DITHER", + "dither_algorithm": "HALFTONE", + "halftone_cell_mm": 0.6, + "halftone_angle": 22.5, + } + ) + assert step.depth_mode == "DITHER" + assert step.dither_algorithm is DitherAlgorithm.HALFTONE + assert step.halftone_cell_mm == 0.6 + assert step.halftone_angle == 22.5 + + def test_build_raster_part_forwards_dither_options(self, machine): + step = EngraveStep(name="engrave") + step.depth_mode = "DITHER" + step.auto_levels = False + step.dither_algorithm = DitherAlgorithm.HALFTONE + step.dither_serpentine = True + step.halftone_cell_mm = 0.7 + step.halftone_angle = 12.0 + wp = WorkPiece(name="wp") + wp.set_size(10.0, 5.0) + surface = MagicMock() + target = "laser_essentials.steps.raster_step.preprocess_raster_image" + with ( + patch.object(WorkPiece, "render_to_pixels", return_value=surface), + patch(target, return_value=(None, None)) as preprocess, + ): + step.build_compute_payload(machine, wp) + + kwargs = preprocess.call_args.kwargs + assert kwargs["dither_algorithm"] is DitherAlgorithm.HALFTONE + assert kwargs["dither_serpentine"] is True + assert kwargs["halftone_cell_mm"] == 0.7 + assert kwargs["halftone_angle"] == 12.0 + assert kwargs["pixels_per_mm_x"] == pytest.approx(20.0) + assert kwargs["pixels_per_mm_y"] == pytest.approx(10.0) diff --git a/rayforge/builtin_addons/rayforge-addon-laser/tests/ui_gtk/test_laser_pages.py b/rayforge/builtin_addons/rayforge-addon-laser/tests/ui_gtk/test_laser_pages.py index 3abb0501e..d07e0c16e 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/tests/ui_gtk/test_laser_pages.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/tests/ui_gtk/test_laser_pages.py @@ -9,7 +9,7 @@ gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") -from gi.repository import Adw +from gi.repository import Adw, Gtk from laser_essentials.steps import EngraveStep, MaterialTestStep from laser_essentials.widgets.contour_page import ContourStepSettingsPage from laser_essentials.widgets.material_test_grid_page import ( @@ -462,6 +462,38 @@ def test_raster_page_mode_visibility(editor, laser_machine, ui_context): assert sample_interval.get_visible() is True +@pytest.mark.ui +def test_raster_page_dither_option_visibility( + editor, laser_machine, ui_context +): + """Serpentine follows the error-diffusion algorithms, the halftone + rows only show for Halftone.""" + step_cls = step_registry.get("EngraveStep") + assert step_cls is not None + step = cast(EngraveStep, step_cls.create(ui_context)) + step.depth_mode = "DITHER" + page = RasterSettingsPage(editor, step) + + serpentine = _row(page, "dither_serpentine") + cell = _row(page, "halftone_cell_mm") + angle = _row(page, "halftone_angle") + + step.set_dither_algorithm("STUCKI") + assert serpentine.get_visible() is True + assert cell.get_visible() is False + assert angle.get_visible() is False + + step.set_dither_algorithm("HALFTONE") + assert serpentine.get_visible() is False + assert cell.get_visible() is True + assert angle.get_visible() is True + + step.depth_mode = "POWER_MODULATION" + step.updated.send(step) + assert serpentine.get_visible() is False + assert cell.get_visible() is False + + @pytest.mark.ui def test_raster_page_shows_the_steps_dither_algorithm( editor, laser_machine, ui_context @@ -483,6 +515,29 @@ def test_raster_page_shows_the_steps_dither_algorithm( assert adapter.get_value() == "STUCKI" +@pytest.mark.ui +def test_raster_page_dither_algorithm_lists_new_methods( + editor, laser_machine, ui_context +): + step_cls = step_registry.get("EngraveStep") + assert step_cls is not None + step = cast(EngraveStep, step_cls.create(ui_context)) + step.depth_mode = "DITHER" + page = RasterSettingsPage(editor, step) + + adapter = page.engrave_widget.adapter_for("dither_algorithm") + assert isinstance(adapter, ComboAdapter) + row = cast(Adw.ComboRow, _row(page, "dither_algorithm")) + model = row.get_model() + assert model is not None + labels = [ + cast(Gtk.StringObject, model.get_item(i)).get_string() + for i in range(model.get_n_items()) + ] + for algo in DitherAlgorithm: + assert algo.display_name in labels + + def _depth_mode_adapter(page: RasterSettingsPage) -> ComboAdapter: adapter = page.engrave_widget.adapter_for("depth_mode") assert isinstance(adapter, ComboAdapter) diff --git a/rayforge/pipeline/stage/assembler_helpers.py b/rayforge/pipeline/stage/assembler_helpers.py index 743ad7118..73629a4d6 100644 --- a/rayforge/pipeline/stage/assembler_helpers.py +++ b/rayforge/pipeline/stage/assembler_helpers.py @@ -27,6 +27,8 @@ from ...core.vectorization_spec import TraceSpec from ...image.dither import ( + DEFAULT_HALFTONE_ANGLE, + DEFAULT_HALFTONE_CELL_MM, DitherAlgorithm, grayscale_to_dithered_array, ) @@ -48,7 +50,8 @@ class DepthMode(Enum): * ``POWER_MODULATION`` — variable power proportional to darkness. * ``CONSTANT_POWER`` — binary mask, constant-power scan lines. - * ``DITHER`` — Floyd-Steinberg / ordered dither to binary. + * ``DITHER`` — error-diffusion, ordered or halftone dither to + binary. * ``MULTI_PASS`` — repeated Z-stepped passes through the depth. """ @@ -298,6 +301,10 @@ def preprocess_raster_image( dither_algorithm: DitherAlgorithm | None = None, laser_spot_x_mm: float = 0.1, pixels_per_mm_x: float = 1.0, + pixels_per_mm_y: float | None = None, + dither_serpentine: bool = False, + halftone_cell_mm: float = DEFAULT_HALFTONE_CELL_MM, + halftone_angle: float = DEFAULT_HALFTONE_ANGLE, ) -> tuple[np.ndarray | None, np.ndarray | None]: """Convert a Cairo surface into an image array for a raster assembler. @@ -327,6 +334,13 @@ def preprocess_raster_image( compute minimum feature size for dithering. pixels_per_mm_x: Rendered pixels-per-mm in X, used with ``laser_spot_x_mm`` for dither minimum feature size. + pixels_per_mm_y: Rendered pixels-per-mm in Y; defaults to + ``pixels_per_mm_x``. Screens use both to keep their cells + square in millimetres. + dither_serpentine: Alternate the scan direction per row for + the error-diffusion algorithms. + halftone_cell_mm: Dot spacing of the halftone screen in mm. + halftone_angle: Halftone screen angle in degrees. Returns: ``(image, alpha)`` where *image* is a 2-D ``uint8`` array @@ -371,6 +385,13 @@ def preprocess_raster_image( gray_image, algo, min_feature_px=min_feature_px, + serpentine=dither_serpentine, + halftone_cell_mm=halftone_cell_mm, + halftone_angle=halftone_angle, + pixels_per_mm=( + pixels_per_mm_x, + pixels_per_mm_y or pixels_per_mm_x, + ), ) return image, None diff --git a/tests/pipeline/stage/test_assembler_helpers.py b/tests/pipeline/stage/test_assembler_helpers.py index 975d8a569..d40e85252 100644 --- a/tests/pipeline/stage/test_assembler_helpers.py +++ b/tests/pipeline/stage/test_assembler_helpers.py @@ -308,6 +308,79 @@ def test_dither_invert_engraves_light_areas_only(self): assert np.all(image[:, :5] == 1) assert np.all(image[:, 5:] == 0) + def _gradient_surface(self, width=120, height=60): + surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, width, height) + ctx = cairo.Context(surface) + gradient = cairo.LinearGradient(0, 0, width, 0) + gradient.add_color_stop_rgb(0, 0, 0, 0) + gradient.add_color_stop_rgb(1, 1, 1, 1) + ctx.set_source(gradient) + ctx.paint() + return surface + + def test_dither_forwards_serpentine(self): + surface = self._gradient_surface() + kwargs = { + "mode": DepthMode.DITHER, + "auto_levels": False, + "dither_algorithm": DitherAlgorithm.STUCKI, + "laser_spot_x_mm": 0.1, + "pixels_per_mm_x": 10.0, + } + plain, _ = preprocess_raster_image(surface, **kwargs) + serpentine, _ = preprocess_raster_image( + surface, dither_serpentine=True, **kwargs + ) + + assert plain is not None and serpentine is not None + assert not np.array_equal(plain, serpentine) + + def test_dither_forwards_halftone_settings(self): + surface = self._gradient_surface() + kwargs = { + "mode": DepthMode.DITHER, + "auto_levels": False, + "dither_algorithm": DitherAlgorithm.HALFTONE, + "laser_spot_x_mm": 0.1, + "pixels_per_mm_x": 10.0, + "pixels_per_mm_y": 10.0, + } + base, _ = preprocess_raster_image( + surface, halftone_cell_mm=1.0, halftone_angle=0.0, **kwargs + ) + coarse, _ = preprocess_raster_image( + surface, halftone_cell_mm=2.0, halftone_angle=0.0, **kwargs + ) + rotated, _ = preprocess_raster_image( + surface, halftone_cell_mm=1.0, halftone_angle=30.0, **kwargs + ) + + assert base is not None and coarse is not None + assert rotated is not None + assert not np.array_equal(base, coarse) + assert not np.array_equal(base, rotated) + + def test_dither_halftone_uses_y_resolution(self): + surface = self._gradient_surface() + kwargs = { + "mode": DepthMode.DITHER, + "auto_levels": False, + "dither_algorithm": DitherAlgorithm.HALFTONE, + "laser_spot_x_mm": 0.1, + "pixels_per_mm_x": 10.0, + "halftone_cell_mm": 1.0, + "halftone_angle": 0.0, + } + square, _ = preprocess_raster_image( + surface, pixels_per_mm_y=10.0, **kwargs + ) + squashed, _ = preprocess_raster_image( + surface, pixels_per_mm_y=5.0, **kwargs + ) + + assert square is not None and squashed is not None + assert not np.array_equal(square, squashed) + def test_mask_scan_returns_binary_no_alpha(self): surface = self._black_surface() image, alpha = preprocess_raster_image( From 854f8d29d0daefede72217a7ef7bc57502b30841 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Fri, 9 Oct 2026 12:47:04 +0200 Subject: [PATCH 4/6] feat(lightburn): keep the image layer's dither mode on import CutSetting_Img layers carried their image mode in ditherMode, which the importer ignored, so every imported photo fell back to Variable Power. Map it onto the Engrave step: dithered, atkinson, stucki, jarvis, ordered, newsprint and halftone become the matching dither algorithm (halftone with its cellsPerInch and halftoneAngle), threshold becomes Constant Power and grayscale Variable Power. Sketch has no counterpart and keeps the default. Refs #514 --- rayforge/image/lightburn/importer.py | 39 ++++++++++- .../lightburn/test_lightburn_importer.py | 69 +++++++++++++++++++ 2 files changed, 107 insertions(+), 1 deletion(-) diff --git a/rayforge/image/lightburn/importer.py b/rayforge/image/lightburn/importer.py index d464053a0..cf3b2c66a 100644 --- a/rayforge/image/lightburn/importer.py +++ b/rayforge/image/lightburn/importer.py @@ -361,11 +361,48 @@ def _shape_to_geometry( return (cut_index, geo) +#: LightBurn image modes (``ditherMode``) that map onto a dither +#: algorithm of the Engrave step. +_LIGHTBURN_DITHER_ALGORITHMS = { + "dithered": "FLOYD_STEINBERG", + "atkinson": "ATKINSON", + "stucki": "STUCKI", + "jarvis": "JARVIS_JUDICE_NINKE", + "ordered": "BAYER8", + "newsprint": "NEWSPRINT", + "halftone": "HALFTONE", +} + + +def _build_image_mode_config(cs: dict[str, Any]) -> dict[str, Any]: + """Translate LightBurn's image mode to Engrave-step settings.""" + mode = cs.get("ditherMode") + if mode == "threshold": + return {"depth_mode": "CONSTANT_POWER", "threshold": 128} + if mode == "grayscale": + return {"depth_mode": "POWER_MODULATION"} + algorithm = _LIGHTBURN_DITHER_ALGORITHMS.get(str(mode)) + if algorithm is None: + return {} + config: dict[str, Any] = { + "depth_mode": "DITHER", + "dither_algorithm": algorithm, + } + if algorithm == "HALFTONE": + cells_per_inch = cs.get("cellsPerInch") + if cells_per_inch: + config["halftone_cell_mm"] = 25.4 / float(cells_per_inch) + angle = cs.get("halftoneAngle") + if angle is not None: + config["halftone_angle"] = float(angle) + return config + + def _build_step_config( cs: dict[str, Any], ) -> dict[str, Any] | None: """Translate LightBurn cut settings to generic step configuration.""" - config: dict[str, Any] = {} + config: dict[str, Any] = _build_image_mode_config(cs) max_power = cs.get("maxPower") if max_power is not None: config["power"] = float(max_power) / 100.0 diff --git a/tests/image/lightburn/test_lightburn_importer.py b/tests/image/lightburn/test_lightburn_importer.py index 92086bfbb..6b76ceea3 100644 --- a/tests/image/lightburn/test_lightburn_importer.py +++ b/tests/image/lightburn/test_lightburn_importer.py @@ -11,6 +11,7 @@ from rayforge.core.step_registry import step_registry from rayforge.core.vectorization_spec import PassthroughSpec from rayforge.image.assembler import ItemAssembler +from rayforge.image.dither import DitherAlgorithm from rayforge.image.lightburn.importer import ( LightBurnImporter, _apply_xform_to_geo, @@ -233,6 +234,55 @@ def test_num_passes_mapping(self): assert config is not None assert config == {"passes": 3} + @pytest.mark.parametrize( + "mode, algorithm", + [ + ("dithered", "FLOYD_STEINBERG"), + ("atkinson", "ATKINSON"), + ("stucki", "STUCKI"), + ("jarvis", "JARVIS_JUDICE_NINKE"), + ("ordered", "BAYER8"), + ("newsprint", "NEWSPRINT"), + ], + ) + def test_dither_mode_mapping(self, mode, algorithm): + config = _build_step_config({"ditherMode": mode}) + assert config == { + "depth_mode": "DITHER", + "dither_algorithm": algorithm, + } + + def test_halftone_mapping_converts_cells_per_inch(self): + cs = { + "ditherMode": "halftone", + "cellsPerInch": 50, + "halftoneAngle": 22.5, + } + config = _build_step_config(cs) + assert config is not None + assert config["depth_mode"] == "DITHER" + assert config["dither_algorithm"] == "HALFTONE" + assert config["halftone_cell_mm"] == pytest.approx(25.4 / 50) + assert config["halftone_angle"] == 22.5 + + def test_halftone_settings_ignored_for_other_modes(self): + cs = {"ditherMode": "stucki", "cellsPerInch": 50, "halftoneAngle": 9} + config = _build_step_config(cs) + assert config is not None + assert "halftone_cell_mm" not in config + assert "halftone_angle" not in config + + def test_threshold_mode_maps_to_constant_power(self): + config = _build_step_config({"ditherMode": "threshold"}) + assert config == {"depth_mode": "CONSTANT_POWER", "threshold": 128} + + def test_grayscale_mode_maps_to_variable_power(self): + config = _build_step_config({"ditherMode": "grayscale"}) + assert config == {"depth_mode": "POWER_MODULATION"} + + def test_unknown_dither_mode_is_ignored(self): + assert _build_step_config({"ditherMode": "sketch"}) is None + def test_all_keys(self): cs = {"maxPower": 70, "speed": 6.66667, "kerf": 0.06, "numPasses": 2} config = _build_step_config(cs) @@ -627,6 +677,25 @@ def test_image_layer_import_produces_engrave_step(self): assert step.min_power_level == 0.1 assert step.max_power_level == 0.5 + def test_image_layer_import_keeps_dither_mode(self): + """images.lbrn2 engraves its bitmaps with LightBurn's + ``dithered`` (Floyd-Steinberg) mode; the step must follow.""" + if step_registry.get("EngraveStep") is None: + pytest.skip("EngraveStep not registered") + importer = self._import("images.lbrn2") + result = importer.get_doc_items() + assert result is not None and result.payload is not None + steps = [ + step + for item in result.payload.items + if isinstance(item, Layer) and item.workflow is not None + for step in item.workflow.steps + ] + assert steps + step = cast(Any, steps[0]) + assert step.depth_mode == "DITHER" + assert step.dither_algorithm is DitherAlgorithm.FLOYD_STEINBERG + class TestApplySettingsDispatch: """ItemAssembler._apply_settings must pick its step type from From 1c1992fd8b01bf839f43699f1843cc6d2e888270 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Fri, 9 Oct 2026 12:47:04 +0200 Subject: [PATCH 5/6] docs: document the new dithering algorithms Refs #514 --- CHANGELOG.md | 6 ++++ README.md | 2 +- website/docs/features/operations/engrave.md | 28 +++++++++++++---- .../current/features/operations/engrave.md | 29 ++++++++++++++---- .../current/features/operations/engrave.md | 30 +++++++++++++++---- .../current/features/operations/engrave.md | 30 +++++++++++++++---- .../current/features/operations/engrave.md | 28 +++++++++++++---- .../current/features/operations/engrave.md | 29 ++++++++++++++---- .../current/features/operations/engrave.md | 28 +++++++++++++---- .../current/features/operations/engrave.md | 23 ++++++++++---- 10 files changed, 184 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b72239885..fbb2354f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 the page size (#517) - Import: GIF, TIFF and WebP bitmaps, traced like PNG. Animated GIFs and multi-page TIFFs import their first frame or page (#517) +- Engrave: more dithering algorithms for photos. The error-diffusion + kernels Atkinson, Stucki, Jarvis-Judice-Ninke, Sierra (3-row, + 2-row, Lite) and Burkes, with an optional serpentine scan, plus a + clustered-dot Newsprint screen and a round-dot Halftone screen with + adjustable cell size and angle. LightBurn imports keep the image + layer's dither mode (#514) - Sketcher: boolean operations. Union, Difference, Intersection and Exclude bake the selected closed regions into multi-ring polygons (outer contours counter-clockwise, hole contours clockwise), so diff --git a/README.md b/README.md index ed9942b33..85df22067 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ We also have a [Discord](https://discord.gg/sTHNdTtpQJ). | **Animated 3D Simulation** | Simulate toolpaths in 3D with animated playback, scrubber, and speed control. | | **Holding Tabs** | Add tabs to contour cuts. Supports manual and automatic placement. | | **Overscan & Kerf Comp.** | Improve engraving quality with overscan; ensure dimensional accuracy with kerf compensation. | -| **Dithering Algorithms** | Floyd-Steinberg and Bayer ordered dithering for high-quality raster engraving. | +| **Dithering Algorithms** | Floyd-Steinberg, Stucki, Jarvis, Atkinson, Sierra, Burkes, Bayer, newsprint and halftone screens. | | **Post-Processors** | Lead-in/lead-out, merge overlapping lines, and crop toolpaths to stock boundary. | | **Advanced Path Generation** | Image tracing, travel time optimization, path smoothing, and spot size interpolation. | diff --git a/website/docs/features/operations/engrave.md b/website/docs/features/operations/engrave.md index 536099346..7849a1240 100644 --- a/website/docs/features/operations/engrave.md +++ b/website/docs/features/operations/engrave.md @@ -229,12 +229,19 @@ This is particularly useful for: Choose the algorithm that best suits your image and material: -| Algorithm | Quality | Speed | Best For | -| --------------- | ------- | ------- | ----------------------------------- | -| Floyd-Steinberg | Highest | Slowest | Photos, portraits, smooth gradients | -| Bayer 2x2 | Low | Fastest | Coarse halftone effect | -| Bayer 4x4 | Medium | Fast | Balanced halftone | -| Bayer 8x8 | High | Medium | Fine detail, subtle patterns | +| Algorithm | Quality | Speed | Best For | +| --------------------------- | ------- | ------- | ------------------------------------- | +| Floyd-Steinberg | Highest | Slowest | Photos, portraits, smooth gradients | +| Bayer 2x2 | Low | Fastest | Coarse halftone effect | +| Bayer 4x4 | Medium | Fast | Balanced halftone | +| Bayer 8x8 | High | Medium | Fine detail, subtle patterns | +| Atkinson | Medium | Medium | High-contrast images, wood | +| Stucki | Highest | Slow | Portraits, smooth skin tones | +| Jarvis, Judice & Ninke | Highest | Slow | Soft gradients, fine grain | +| Sierra (3-row, 2-row, Lite) | High | Medium | Lighter alternatives to Stucki | +| Burkes | High | Medium | Photos, faster than Stucki | +| Newsprint | Medium | Fastest | Clustered newspaper-style dots | +| Halftone | Medium | Fast | Round dots, adjustable size and angle | **Floyd-Steinberg** is default and recommended for most photo engravings. It uses error diffusion to distribute quantization errors to neighboring pixels, creating natural-looking results. @@ -242,6 +249,15 @@ distribute quantization errors to neighboring pixels, creating natural-looking r **Bayer dithering** creates regular patterns that can produce artistic effects resembling traditional halftone printing. +**Stucki, Jarvis, Sierra, Burkes and Atkinson** are error-diffusion algorithms like Floyd-Steinberg +that spread the error over a wider neighborhood, which gives a smoother grain in flat areas such as +skin tones. Atkinson diffuses only part of the error, which raises contrast. Turn on **Serpentine** +to alternate the scan direction on every row and avoid directional streaks. + +**Newsprint** and **Halftone** group pixels into dots. Halftone draws round dots on a grid set by +**Halftone Cell Size** (the distance between dot centers) and **Halftone Angle**; use a cell size of +at least a few laser spot widths. + #### Multiple Depths Mode Settings ![Multiple Depths mode settings](/screenshots/step-settings-engrave-general-multi_pass.webp) diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/engrave.md b/website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/engrave.md index 27e5f46ca..c245fac9c 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/engrave.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/engrave.md @@ -227,12 +227,19 @@ Dies ist besonders nützlich für: Wähle den Algorithmus, der am besten zu deinem Bild und Material passt: -| Algorithmus | Qualität | Geschwindigkeit | Am besten für | -| --------------- | -------- | --------------- | -------------------------------- | -| Floyd-Steinberg | Höchste | Langsamste | Fotos, Porträts, sanfte Verläufe | -| Bayer 2x2 | Niedrig | Schnellste | Grober Halbton-Effekt | -| Bayer 4x4 | Mittel | Schnell | Ausgewogener Halbton | -| Bayer 8x8 | Hoch | Mittel | Feine Details, subtile Muster | +| Algorithmus | Qualität | Geschwindigkeit | Am besten für | +| --------------------------------- | -------- | --------------- | ------------------------------------------ | +| Floyd-Steinberg | Höchste | Langsamste | Fotos, Porträts, sanfte Verläufe | +| Bayer 2x2 | Niedrig | Schnellste | Grober Halbton-Effekt | +| Bayer 4x4 | Mittel | Schnell | Ausgewogener Halbton | +| Bayer 8x8 | Hoch | Mittel | Feine Details, subtile Muster | +| Atkinson | Mittel | Mittel | Kontrastreiche Bilder, Holz | +| Stucki | Höchste | Langsam | Porträts, weiche Hauttöne | +| Jarvis, Judice & Ninke | Höchste | Langsam | Sanfte Verläufe, feine Körnung | +| Sierra (3-zeilig, 2-zeilig, Lite) | Hoch | Mittel | Leichtere Alternativen zu Stucki | +| Burkes | Hoch | Mittel | Fotos, schneller als Stucki | +| Zeitungsdruck | Mittel | Schnellste | Gruppierte Punkte wie im Zeitungsdruck | +| Halbton | Mittel | Schnell | Runde Punkte, Größe und Winkel einstellbar | **Floyd-Steinberg** ist Standard und empfohlen für die meisten Fotogravuren. Es verwendet Fehler-Diffusion, um Quantisierungsfehler auf benachbarte Pixel zu verteilen, was natürlich @@ -241,6 +248,16 @@ aussehende Ergebnisse erzeugt. **Bayer-Dithering** erzeugt regelmäßige Muster, die künstlerische Effekte erzeugen können, die traditionellem Halbton-Druck ähneln. +**Stucki, Jarvis, Sierra, Burkes und Atkinson** sind wie Floyd-Steinberg Fehler-Diffusionsverfahren, +verteilen den Fehler aber über eine größere Umgebung. Das ergibt eine ruhigere Körnung in +gleichmäßigen Flächen wie Hauttönen. Atkinson verteilt nur einen Teil des Fehlers und erhöht so den +Kontrast. Schalte **Serpentinenmodus** ein, um die Abtastrichtung in jeder Zeile zu wechseln und +richtungsabhängige Streifen zu vermeiden. + +**Zeitungsdruck** und **Halbton** fassen Pixel zu Punkten zusammen. Halbton setzt runde Punkte auf +ein Raster, das du mit **Halbton-Zellgröße** (Abstand zwischen den Punktmittelpunkten) und +**Halbton-Winkel** einstellst. Wähle eine Zellgröße von mindestens einigen Laserpunktbreiten. + #### Mehrfach-Tiefen-Modus-Einstellungen ![Einstellungen für Mehrfach-Tiefen-Modus](/screenshots/step-settings-engrave-general-multi_pass.webp) diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/engrave.md b/website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/engrave.md index 58d12d5a4..d55355e60 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/engrave.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/engrave.md @@ -226,12 +226,19 @@ Esto es particularmente útil para: Elige el algoritmo que mejor se adapte a tu imagen y material: -| Algoritmo | Calidad | Velocidad | Mejor Para | -| --------------- | ------- | ---------- | ---------------------------------- | -| Floyd-Steinberg | Máxima | Más lento | Fotos, retratos, degradados suaves | -| Bayer 2x2 | Baja | Más rápido | Efecto de media tinta grueso | -| Bayer 4x4 | Media | Rápido | Media tinta equilibrada | -| Bayer 8x8 | Alta | Media | Detalle fino, patrones sutiles | +| Algoritmo | Calidad | Velocidad | Mejor Para | +| ------------------------------- | ------- | ---------- | ------------------------------------------- | +| Floyd-Steinberg | Máxima | Más lento | Fotos, retratos, degradados suaves | +| Bayer 2x2 | Baja | Más rápido | Efecto de media tinta grueso | +| Bayer 4x4 | Media | Rápido | Media tinta equilibrada | +| Bayer 8x8 | Alta | Media | Detalle fino, patrones sutiles | +| Atkinson | Media | Media | Imágenes de alto contraste, madera | +| Stucki | Máxima | Lento | Retratos, tonos de piel suaves | +| Jarvis, Judice y Ninke | Máxima | Lento | Degradados suaves, grano fino | +| Sierra (3 filas, 2 filas, Lite) | Alta | Media | Alternativas más ligeras a Stucki | +| Burkes | Alta | Media | Fotos, más rápido que Stucki | +| Papel prensa | Media | Más rápido | Puntos agrupados al estilo periódico | +| Semitono | Media | Rápido | Puntos redondos, tamaño y ángulo ajustables | **Floyd-Steinberg** es el predeterminado y el recomendado para la mayoría de los grabados de fotos. Usa difusión de error para distribuir los errores de cuantización a los píxeles vecinos, creando @@ -240,6 +247,17 @@ resultados de aspecto natural. **El tramado Bayer** crea patrones regulares que pueden producir efectos artísticos que asemejan la impresión tradicional de media tinta. +**Stucki, Jarvis, Sierra, Burkes y Atkinson** son algoritmos de difusión de error como +Floyd-Steinberg que reparten el error en un entorno más amplio, lo que da un grano más uniforme en +zonas planas como los tonos de piel. Atkinson solo difunde parte del error, lo que aumenta el +contraste. Activa **Serpentina** para alternar la dirección de barrido en cada fila y evitar rayas +direccionales. + +**Papel prensa** y **Semitono** agrupan los píxeles en puntos. Semitono dibuja puntos redondos sobre +una cuadrícula definida por **Tamaño de celda del semitono** (la distancia entre los centros de los +puntos) y **Ángulo del semitono**; usa un tamaño de celda de al menos varias veces el ancho del +punto láser. + #### Ajustes del Modo Múltiples Profundidades ![Ajustes del modo Múltiples Profundidades](/screenshots/step-settings-engrave-general-multi_pass.webp) diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/engrave.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/engrave.md index ca536dd66..ffd7947e1 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/engrave.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/engrave.md @@ -227,12 +227,19 @@ C'est particulièrement utile pour : Choisissez l'algorithme qui convient le mieux à votre image et à votre matériau : -| Algorithme | Qualité | Vitesse | Idéal Pour | -| --------------- | -------------- | -------------- | ---------------------------------- | -| Floyd-Steinberg | La plus élevée | La plus lente | Photos, portraits, dégradés lisses | -| Bayer 2x2 | Basse | La plus rapide | Effet demi-teinte grossier | -| Bayer 4x4 | Moyenne | Rapide | Demi-teinte équilibrée | -| Bayer 8x8 | Élevée | Moyenne | Détails fins, motifs subtils | +| Algorithme | Qualité | Vitesse | Idéal Pour | +| --------------------------------- | -------------- | -------------- | --------------------------------------- | +| Floyd-Steinberg | La plus élevée | La plus lente | Photos, portraits, dégradés lisses | +| Bayer 2x2 | Basse | La plus rapide | Effet demi-teinte grossier | +| Bayer 4x4 | Moyenne | Rapide | Demi-teinte équilibrée | +| Bayer 8x8 | Élevée | Moyenne | Détails fins, motifs subtils | +| Atkinson | Moyenne | Moyenne | Images contrastées, bois | +| Stucki | La plus élevée | Lente | Portraits, tons chair doux | +| Jarvis, Judice et Ninke | La plus élevée | Lente | Dégradés doux, grain fin | +| Sierra (3 lignes, 2 lignes, Lite) | Élevée | Moyenne | Alternatives plus légères à Stucki | +| Burkes | Élevée | Moyenne | Photos, plus rapide que Stucki | +| Papier journal | Moyenne | La plus rapide | Points groupés façon journal | +| Demi-teinte | Moyenne | Rapide | Points ronds, taille et angle réglables | **Floyd-Steinberg** est le défaut et recommandé pour la plupart des gravures photo. Il utilise la diffusion d'erreur pour distribuer les erreurs de quantification aux pixels voisins, créant des @@ -241,6 +248,17 @@ résultats d'apparence naturelle. **Le tramage Bayer** crée des motifs réguliers qui peuvent produire des effets artistiques ressemblant à l'impression demi-teinte traditionnelle. +**Stucki, Jarvis, Sierra, Burkes et Atkinson** sont des algorithmes de diffusion d'erreur comme +Floyd-Steinberg qui répartissent l'erreur sur un voisinage plus large, ce qui donne un grain plus +régulier dans les zones uniformes comme les tons chair. Atkinson ne diffuse qu'une partie de +l'erreur, ce qui augmente le contraste. Active **Serpentin** pour alterner le sens de balayage à +chaque ligne et éviter les stries directionnelles. + +**Papier journal** et **Demi-teinte** regroupent les pixels en points. Demi-teinte trace des points +ronds sur une grille définie par **Taille de cellule de demi-teinte** (la distance entre les centres +des points) et **Angle de demi-teinte** ; choisis une taille de cellule d'au moins quelques largeurs +de point laser. + #### Paramètres du Mode Profondeurs Multiples ![Paramètres du mode Profondeurs Multiples](/screenshots/step-settings-engrave-general-multi_pass.webp) diff --git a/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/engrave.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/engrave.md index 399dba324..675d3014d 100644 --- a/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/engrave.md +++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/engrave.md @@ -230,12 +230,19 @@ _Step Settings_ टैब के **उत्कीर्णन** और **शक वह एल्गोरिदम चुनें जो आपकी छवि और सामग्री के लिए सर्वोत्तम हो: -| एल्गोरिदम | गुणवत्ता | गति | सर्वोत्तम के लिए | -| --------------- | -------- | --------- | --------------------------------- | -| Floyd-Steinberg | सर्वाधिक | सबसे धीमा | फ़ोटो, पोर्ट्रेट, चिकने ग्रेडिएंट | -| Bayer 2x2 | कम | सबसे तेज़ | मोटा हाफ़टोन प्रभाव | -| Bayer 4x4 | मध्यम | तेज़ | संतुलित हाफ़टोन | -| Bayer 8x8 | अधिक | मध्यम | महीन विवरण, सूक्ष्म पैटर्न | +| एल्गोरिदम | गुणवत्ता | गति | सर्वोत्तम के लिए | +| --------------------------------- | -------- | --------- | --------------------------------- | +| Floyd-Steinberg | सर्वाधिक | सबसे धीमा | फ़ोटो, पोर्ट्रेट, चिकने ग्रेडिएंट | +| Bayer 2x2 | कम | सबसे तेज़ | मोटा हाफ़टोन प्रभाव | +| Bayer 4x4 | मध्यम | तेज़ | संतुलित हाफ़टोन | +| Bayer 8x8 | अधिक | मध्यम | महीन विवरण, सूक्ष्म पैटर्न | +| Atkinson | मध्यम | मध्यम | उच्च कंट्रास्ट वाली छवियाँ, लकड़ी | +| Stucki | सर्वाधिक | धीमा | पोर्ट्रेट, चिकने त्वचा टोन | +| Jarvis, Judice और Ninke | सर्वाधिक | धीमा | मुलायम ग्रेडिएंट, महीन दाना | +| Sierra (3-पंक्ति, 2-पंक्ति, Lite) | अधिक | मध्यम | Stucki के हल्के विकल्प | +| Burkes | अधिक | मध्यम | फ़ोटो, Stucki से तेज़ | +| अख़बारी छपाई | मध्यम | सबसे तेज़ | अख़बार जैसे समूहित बिंदु | +| हाफ़टोन | मध्यम | तेज़ | गोल बिंदु, आकार और कोण समायोज्य | **Floyd-Steinberg** डिफ़ॉल्ट है और अधिकांश फ़ोटो उत्कीर्णनों के लिए अनुशंसित. यह मात्रीकरण त्रुटियों को पड़ोसी पिक्सेलों में वितरित करने के लिए त्रुटि विसरण उपयोग करता है, जिससे प्राकृतिक दिखने वाले @@ -243,6 +250,15 @@ _Step Settings_ टैब के **उत्कीर्णन** और **शक **Bayer डाइदरिंग** नियमित पैटर्न बनाती है जो पारंपरिक हाफ़टोन छपाई जैसे कलात्मक प्रभाव दे सकते हैं. +**Stucki, Jarvis, Sierra, Burkes और Atkinson** Floyd-Steinberg की तरह त्रुटि-प्रसार एल्गोरिदम हैं, +जो त्रुटि को अधिक बड़े पड़ोस में फैलाते हैं. इससे त्वचा टोन जैसे समतल क्षेत्रों में दाना अधिक चिकना +होता है. Atkinson त्रुटि का केवल एक भाग फैलाता है, जिससे कंट्रास्ट बढ़ता है. हर पंक्ति में स्कैन +दिशा बदलने और दिशात्मक धारियों से बचने के लिए **सर्पाकार** चालू करें. + +**अख़बारी छपाई** और **हाफ़टोन** पिक्सेल को बिंदुओं में समूहित करते हैं. हाफ़टोन एक ग्रिड पर गोल +बिंदु बनाता है, जिसे **हाफ़टोन सेल आकार** (बिंदु केंद्रों के बीच की दूरी) और **हाफ़टोन कोण** से तय +किया जाता है; सेल आकार कम से कम कुछ लेज़र बिंदु चौड़ाई जितना रखें. + #### अनेक गहराइयाँ मोड सेटिंग्स ![अनेक गहराइयाँ मोड सेटिंग्स](/screenshots/step-settings-engrave-general-multi_pass.webp) diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/engrave.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/engrave.md index 141e4901f..05d9c7035 100644 --- a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/engrave.md +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/engrave.md @@ -226,12 +226,19 @@ Isso é particularmente útil para: Escolha o algoritmo que melhor se adapta à sua imagem e material: -| Algoritmo | Qualidade | Velocidade | Melhor Para | -| --------------- | --------- | ----------- | ---------------------------------- | -| Floyd-Steinberg | Mais Alta | Mais Lento | Fotos, retratos, gradientes suaves | -| Bayer 2x2 | Baixa | Mais Rápido | Efeito meio-tom grosseiro | -| Bayer 4x4 | Média | Rápido | Meio-tom balanceado | -| Bayer 8x8 | Alta | Médio | Detalhe fino, padrões sutis | +| Algoritmo | Qualidade | Velocidade | Melhor Para | +| --------------------------------- | --------- | ----------- | -------------------------------------------- | +| Floyd-Steinberg | Mais Alta | Mais Lento | Fotos, retratos, gradientes suaves | +| Bayer 2x2 | Baixa | Mais Rápido | Efeito meio-tom grosseiro | +| Bayer 4x4 | Média | Rápido | Meio-tom balanceado | +| Bayer 8x8 | Alta | Médio | Detalhe fino, padrões sutis | +| Atkinson | Média | Médio | Imagens de alto contraste, madeira | +| Stucki | Mais Alta | Lento | Retratos, tons de pele suaves | +| Jarvis, Judice e Ninke | Mais Alta | Lento | Gradientes suaves, grão fino | +| Sierra (3 linhas, 2 linhas, Lite) | Alta | Médio | Alternativas mais leves ao Stucki | +| Burkes | Alta | Médio | Fotos, mais rápido que Stucki | +| Papel de jornal | Média | Mais Rápido | Pontos agrupados no estilo jornal | +| Meio-tom | Média | Rápido | Pontos redondos, tamanho e ângulo ajustáveis | **Floyd-Steinberg** é padrão e recomendado para a maioria das gravações de fotos. Usa difusão de erro para distribuir erros de quantização para pixels vizinhos, criando resultados de aparência @@ -240,6 +247,16 @@ natural. **Pontilhamento Bayer** cria padrões regulares que podem produzir efeitos artísticos lembrando impressão tradicional de meio-tom. +**Stucki, Jarvis, Sierra, Burkes e Atkinson** são algoritmos de difusão de erro como o +Floyd-Steinberg que espalham o erro por uma vizinhança maior, o que dá um grão mais uniforme em +áreas planas como tons de pele. O Atkinson difunde só parte do erro, o que aumenta o contraste. +Ative **Serpentina** para alternar a direção de varredura em cada linha e evitar listras +direcionais. + +**Papel de jornal** e **Meio-tom** agrupam os pixels em pontos. O meio-tom desenha pontos redondos +numa grade definida por **Tamanho da célula de meio-tom** (a distância entre os centros dos pontos) +e **Ângulo do meio-tom**; use um tamanho de célula de pelo menos algumas larguras do ponto do laser. + #### Configurações do Modo Múltiplas Profundidades ![Configurações do modo Múltiplas Profundidades](/screenshots/step-settings-engrave-general-multi_pass.webp) diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/engrave.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/engrave.md index c40e63e49..4e960182c 100644 --- a/website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/engrave.md +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/engrave.md @@ -226,12 +226,19 @@ Виберіть алгоритм, який найкраще підходить для вашого зображення та матеріалу: -| Алгоритм | Якість | Швидкість | Найкраще для | -| --------------- | ------- | ------------- | -------------------------------- | -| Floyd-Steinberg | Найвища | Найповільніша | Фото, портрети, плавні градієнти | -| Bayer 2x2 | Низька | Найшвидша | Грубий ефект півтону | -| Bayer 4x4 | Середня | Швидка | Збалансований півтони | -| Bayer 8x8 | Висока | Середня | Тонкі деталі, тонкі патерни | +| Алгоритм | Якість | Швидкість | Найкраще для | +| ------------------------------------- | ------- | ------------- | -------------------------------------- | +| Floyd-Steinberg | Найвища | Найповільніша | Фото, портрети, плавні градієнти | +| Bayer 2x2 | Низька | Найшвидша | Грубий ефект півтону | +| Bayer 4x4 | Середня | Швидка | Збалансований півтони | +| Bayer 8x8 | Висока | Середня | Тонкі деталі, тонкі патерни | +| Atkinson | Середня | Середня | Контрастні зображення, дерево | +| Stucki | Найвища | Повільна | Портрети, м'які тони шкіри | +| Jarvis, Judice і Ninke | Найвища | Повільна | М'які градієнти, дрібне зерно | +| Sierra (3-рядковий, 2-рядковий, Lite) | Висока | Середня | Легші альтернативи Stucki | +| Burkes | Висока | Середня | Фото, швидше за Stucki | +| Газетний друк | Середня | Найшвидша | Згруповані точки як у газеті | +| Півтон | Середня | Швидка | Круглі точки, регульовані розмір і кут | **Floyd-Steinberg** — за замовчуванням та рекомендується для більшості фотогравіювань. Він використовує дифузію помилок для розподілу помилок квантування до сусідніх пікселів, створюючи @@ -240,6 +247,15 @@ **Дизеринг Байєра** створює регулярні патерни, які можуть давати художні ефекти, що нагадують традиційний друк півтонами. +**Stucki, Jarvis, Sierra, Burkes і Atkinson** — алгоритми дифузії помилки, як Floyd-Steinberg, що +розподіляють помилку на ширше оточення. Це дає рівніше зерно на однорідних ділянках, наприклад на +тонах шкіри. Atkinson розподіляє лише частину помилки, що підвищує контраст. Увімкніть **Змійкою**, +щоб змінювати напрямок сканування в кожному рядку й уникати спрямованих смуг. + +**Газетний друк** і **Півтон** групують пікселі в точки. Півтон малює круглі точки на сітці, яку +задають **Розмір комірки півтону** (відстань між центрами точок) і **Кут півтону**; обирайте розмір +комірки щонайменше в кілька ширин лазерної плями. + #### Налаштування режиму кількох глибин ![Налаштування режиму кількох глибин](/screenshots/step-settings-engrave-general-multi_pass.webp) diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/engrave.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/engrave.md index 7011da6c1..5bd1da9cc 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/engrave.md +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/engrave.md @@ -211,18 +211,29 @@ 选择最适合您的图像和材料的算法: -| 算法 | 质量 | 速度 | 最适用于 | -| --------------- | ---- | ---- | -------------------- | -| Floyd-Steinberg | 最高 | 最慢 | 照片、人像、平滑渐变 | -| Bayer 2x2 | 低 | 最快 | 粗糙的半色调效果 | -| Bayer 4x4 | 中等 | 快 | 平衡的半色调 | -| Bayer 8x8 | 高 | 中等 | 精细细节、微妙的图案 | +| 算法 | 质量 | 速度 | 最适用于 | +| -------------------------- | ---- | ---- | ------------------------ | +| Floyd-Steinberg | 最高 | 最慢 | 照片、人像、平滑渐变 | +| Bayer 2x2 | 低 | 最快 | 粗糙的半色调效果 | +| Bayer 4x4 | 中等 | 快 | 平衡的半色调 | +| Bayer 8x8 | 高 | 中等 | 精细细节、微妙的图案 | +| Atkinson | 中等 | 中等 | 高对比度图像、木材 | +| Stucki | 最高 | 慢 | 人像、平滑肤色 | +| Jarvis, Judice & Ninke | 最高 | 慢 | 柔和渐变、细腻颗粒 | +| Sierra(三行、两行、Lite) | 高 | 中等 | 比 Stucki 更轻量的替代 | +| Burkes | 高 | 中等 | 照片,比 Stucki 快 | +| 报纸印刷 | 中等 | 最快 | 报纸风格的聚集网点 | +| 半色调 | 中等 | 快 | 圆形网点,尺寸和角度可调 | **Floyd-Steinberg** 是默认选择,推荐用于大多数照片雕刻。它使用误差扩散将量化误差分配到相邻像素,从而产生自然的效果。 **Bayer 抖动**会创建规则的图案,可产生类似传统半色调印刷的艺术效果。 +**Stucki、Jarvis、Sierra、Burkes 和 Atkinson** 与 Floyd-Steinberg 一样属于误差扩散算法,但会把误差分散到更大的邻域,在肤色等平坦区域中颗粒更平滑。Atkinson 只扩散部分误差,因此对比度更高。启用**蛇形扫描**可在每一行交替扫描方向,避免方向性条纹。 + +**报纸印刷**和**半色调**会把像素聚集成网点。半色调在网格上绘制圆形网点,网格由**半色调网格尺寸**(网点中心之间的距离)和**半色调角度**决定;网格尺寸应至少为激光光斑宽度的几倍。 + #### 多深度模式设置 ![多深度模式设置](/screenshots/step-settings-engrave-general-multi_pass.webp) From 27cbfbd158b4b90558e2523356b5b59682878354 Mon Sep 17 00:00:00 2001 From: Samuel Abels Date: Sun, 11 Oct 2026 00:50:08 +0200 Subject: [PATCH 6/6] refactor(image): back the dither algorithms with raygeo 1.62 The error-diffusion kernels, the serpentine scan and the halftone and newsprint screens now live in raygeo (barebaric/raygeo#55, released as 1.62.0) instead of being reimplemented here. rayforge/image/dither keeps the DitherAlgorithm catalogue and dispatches to raygeo, translating the enum values to raygeo's kernel names; the Python-side kernels, spot function and screen builders are gone. Bump the raygeo pin to ==1.62.0 in pixi.toml, requirements.txt and debian/requirements-bundle.txt, and relock pixi.lock. --- debian/requirements-bundle.txt | 2 +- pixi.lock | 109 ++++----- pixi.toml | 2 +- rayforge/image/dither.py | 308 ++------------------------ requirements.txt | 2 +- tests/image/test_dither_algorithms.py | 162 +++----------- 6 files changed, 115 insertions(+), 470 deletions(-) diff --git a/debian/requirements-bundle.txt b/debian/requirements-bundle.txt index e9fe1a1ef..0d43efc59 100644 --- a/debian/requirements-bundle.txt +++ b/debian/requirements-bundle.txt @@ -1,7 +1,7 @@ asyncudp==0.11.0 pyvips==3.2.0 raydriver==0.3.0 -raygeo==1.61.0 +raygeo==1.62.0 ruida-pa==0.21.2 trimesh==5.1.1 vtracer==0.6.15 \ No newline at end of file diff --git a/pixi.lock b/pixi.lock index ed6bb2c56..7390f5939 100644 --- a/pixi.lock +++ b/pixi.lock @@ -1,7 +1,16 @@ version: 7 platforms: - name: linux-64 + virtual-packages: + - __unix=0=0 + - __linux=4.18 + - __glibc=2.28 + - __archspec=0=x86_64 - name: osx-arm64 + virtual-packages: + - __unix=0=0 + - __osx=13.0 + - __archspec=0=m1 environments: appimage: channels: @@ -228,7 +237,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/sysroot_linux-64-2.28-h4ee821c_9.conda - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.16.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda - - pypi: . + - pypi: ./ - pypi: https://files.pythonhosted.org/packages/03/43/344fbf03470b38c11c334fd1ea9f12212036c93f64887d1f31672a95fd8d/ruida_pa-0.22.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/03/c0/a6d8be9c43e4456fb9597fdf8b5e0ce1f0a5df41503acce6d869536e4e23/websockets-17.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl @@ -242,6 +251,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3a/cf/9c2e4d6c179529d5bf4a64cff76fa581486569e9fbdd35bd98f51cb624bf/msgpack-1.2.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/3c/2c/c43c03eaf630435f023f1dc61ec4a4a78951ad5530a62c71cc89bde307b7/pypdf-6.19.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/3d/de/02f631997741485881fbe129aef2de75a23b4e9776f5b1c4a7afc313ae7e/raygeo-1.62.0.tar.gz - pypi: https://files.pythonhosted.org/packages/3f/99/2e119d541d596daea39643eb9312b47c7847383951300f889166938035b1/rpyc-6.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/41/51/8041c2e70649e5b7f2a0aedbbbd0609ac099cfaa0cbde2014279c9c05756/cairosvg-2.9.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/4f/a7/aeedb5140afa41fc74c225e9184ab96723a6e873b6ee1c9fede7283456d8/markupsafe-3.0.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl @@ -280,7 +290,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/c5/9b/6ce1ead737fe496611bda600c263b9a11ae7bd8f41bb13f9bd7e0a2c37a4/platformdirs-4.12.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c7/06/dace3e27af26690cb20bead80dbac42941b0841eb689b8aabbd67dde16f0/pymupdf-1.28.2-cp310-abi3-manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/cf/4c/c73f828fdbcd37eaf21d08fa852544a3ca7c2dbb3ea76873d64f2ea413d1/opencv_python-5.0.0.93-cp37-abi3-manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/dd/e9/fe7809075e9b29f9d938950eefe7a3da84d0f69be540d6dc1aa5a25ef02e/raygeo-1.61.0.tar.gz - pypi: https://files.pythonhosted.org/packages/df/f3/90993aab504fa2e1f28fcc09aa16b6ea4f00e75a037d9136e737855833e2/pyvips-3.2.0.tar.gz - pypi: https://files.pythonhosted.org/packages/e7/29/2094fd7719146ca7bb236be9751b5929d431f643cbab669aed89cf121883/ezdxf-1.4.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/ed/5b/db08419c1e1f7c9d60cfd2787b2b517d7ae4ebbda8281b48a33eb5141467/multidict-6.9.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl @@ -461,7 +470,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/xorg-libxt-1.3.1-hb001647_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zlib-1.3.2-h8088a28_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hf451053_7.conda - - pypi: . + - pypi: ./ - pypi: https://files.pythonhosted.org/packages/03/43/344fbf03470b38c11c334fd1ea9f12212036c93f64887d1f31672a95fd8d/ruida_pa-0.22.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl @@ -471,11 +480,11 @@ environments: - pypi: https://files.pythonhosted.org/packages/13/d4/1152d1c7ab42d8b908be64fd200ddc870dc9d4925e951198702084aa1a7d/linkify_it_py-2.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/16/67/1f0b582e73ce46511f2799cbdcdbdf9ca98f344f2aad42ff5ff5dde0d8fe/narwhals-2.27.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/17/fe/77f4a24264e728fd95542e53f026a91249b51d1611087b8c82d3a033ea97/asyncudp-0.11.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/20/a5/1d1baffeb743cbdfff98c180473a9c81bbabe433dea5a72761d4c1edf1ef/raygeo-1.62.0-cp311-abi3-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3c/2c/c43c03eaf630435f023f1dc61ec4a4a78951ad5530a62c71cc89bde307b7/pypdf-6.19.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3f/99/2e119d541d596daea39643eb9312b47c7847383951300f889166938035b1/rpyc-6.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/41/51/8041c2e70649e5b7f2a0aedbbbd0609ac099cfaa0cbde2014279c9c05756/cairosvg-2.9.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/4b/5c/7bb93dd4c4c755c5bf99e9ffadeb4ec8a6477fd229994fdcbc74e25a76b7/raygeo-1.61.0-cp311-abi3-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5c/09/1abf942723c0001d9c2fca1551907dade6304517b982b0bf10bba107fa81/websockets-17.2-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/60/45/c7b5c3168458db837e8ceab06dc77824e18202679d0463f0e8f002143a97/tinycss2-1.5.1-py3-none-any.whl @@ -906,7 +915,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.16.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda - - pypi: . + - pypi: ./ - pypi: https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/03/43/344fbf03470b38c11c334fd1ea9f12212036c93f64887d1f31672a95fd8d/ruida_pa-0.22.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/03/c0/a6d8be9c43e4456fb9597fdf8b5e0ce1f0a5df41503acce6d869536e4e23/websockets-17.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl @@ -924,6 +933,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3a/cf/9c2e4d6c179529d5bf4a64cff76fa581486569e9fbdd35bd98f51cb624bf/msgpack-1.2.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/3c/2c/c43c03eaf630435f023f1dc61ec4a4a78951ad5530a62c71cc89bde307b7/pypdf-6.19.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/3d/de/02f631997741485881fbe129aef2de75a23b4e9776f5b1c4a7afc313ae7e/raygeo-1.62.0.tar.gz - pypi: https://files.pythonhosted.org/packages/3f/99/2e119d541d596daea39643eb9312b47c7847383951300f889166938035b1/rpyc-6.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/41/51/8041c2e70649e5b7f2a0aedbbbd0609ac099cfaa0cbde2014279c9c05756/cairosvg-2.9.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/45/e2/bbb7129c9e7999a6b8ee9cca3b66486c25c423ab5a75f34071798b74ce94/pre_commit-4.6.2-py2.py3-none-any.whl @@ -974,7 +984,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/d7/ba/18b6e682ead424ad24bcc134339ae5d1b931cd9ae260540592a058a91279/pyright-1.1.414-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/db/5b/b83a9bf1a3b4ec222f9fa083147ff6816245223da0ab92370e7e056f113f/pytest_mock-3.16.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/dd/e9/fe7809075e9b29f9d938950eefe7a3da84d0f69be540d6dc1aa5a25ef02e/raygeo-1.61.0.tar.gz - pypi: https://files.pythonhosted.org/packages/df/93/084f10ebbecdedf7902ed68fbecc903f0e9c5742155bfffb05eccbc071da/virtualenv-21.14.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/df/f3/90993aab504fa2e1f28fcc09aa16b6ea4f00e75a037d9136e737855833e2/pyvips-3.2.0.tar.gz - pypi: https://files.pythonhosted.org/packages/e4/03/17234145f302a645a123e8c3bb2411ecf4669fbc350de3b0d803230a1729/ruff-0.16.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl @@ -1166,7 +1175,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/xorg-libxt-1.3.1-hb001647_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zlib-1.3.2-h8088a28_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hf451053_7.conda - - pypi: . + - pypi: ./ - pypi: https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/03/43/344fbf03470b38c11c334fd1ea9f12212036c93f64887d1f31672a95fd8d/ruida_pa-0.22.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl @@ -1179,6 +1188,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/16/67/1f0b582e73ce46511f2799cbdcdbdf9ca98f344f2aad42ff5ff5dde0d8fe/narwhals-2.27.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/16/7d/e9ffbadfbf89c93848412d04594135c4ae8c1d37d9e053b9c3ed718fabc4/python_discovery-1.6.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/17/fe/77f4a24264e728fd95542e53f026a91249b51d1611087b8c82d3a033ea97/asyncudp-0.11.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/20/a5/1d1baffeb743cbdfff98c180473a9c81bbabe433dea5a72761d4c1edf1ef/raygeo-1.62.0-cp311-abi3-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3c/2c/c43c03eaf630435f023f1dc61ec4a4a78951ad5530a62c71cc89bde307b7/pypdf-6.19.0-py3-none-any.whl @@ -1186,7 +1196,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/41/51/8041c2e70649e5b7f2a0aedbbbd0609ac099cfaa0cbde2014279c9c05756/cairosvg-2.9.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/45/e2/bbb7129c9e7999a6b8ee9cca3b66486c25c423ab5a75f34071798b74ce94/pre_commit-4.6.2-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/4a/4d/2983a9fc0a412a09151299d1ec9eb7511e3d577f7213e19395fabebf9db6/filelock-4.0.11-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/4b/5c/7bb93dd4c4c755c5bf99e9ffadeb4ec8a6477fd229994fdcbc74e25a76b7/raygeo-1.61.0-cp311-abi3-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/54/c8/12811c9b48fde162bb72b6f2e78fada9a247a09d7bb5be2050a5d099c77b/nodeenv-1.11.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/56/e2/480bcdb153feecde4d736b18c908c7eee4846f11d7547d2e0c71499af630/pycodestyle-2.15.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl @@ -9634,11 +9643,11 @@ packages: - zstd >=1.5.7,<1.6.0a0 size: 433687 timestamp: 1786599629846 -- pypi: . +- pypi: ./ name: rayforge requires_dist: - raydriver==0.3.0 - - raygeo==1.61.0 + - raygeo==1.62.0 - aiohttp==3.14.3 - asyncudp==0.11.0 - blinker==1.9.0 @@ -9811,6 +9820,26 @@ packages: name: asyncudp version: 0.11.0 sha256: 96d859d86471e3ed30cf9f05cb38e96920156e3a9fe70ee916096ba758ea316e +- pypi: https://files.pythonhosted.org/packages/20/a5/1d1baffeb743cbdfff98c180473a9c81bbabe433dea5a72761d4c1edf1ef/raygeo-1.62.0-cp311-abi3-macosx_11_0_arm64.whl + name: raygeo + version: 1.62.0 + sha256: 094187c356b390e729a8fa7287b97f7f657b26a280cf2014c6726e944786a4a3 + requires_dist: + - msgpack + - numpy>=1.20.0 + - matplotlib ; extra == 'cli' + - matplotlib ; extra == 'docs' + - mdformat ; extra == 'docs' + - mdformat-frontmatter ; extra == 'docs' + - mdformat-tables ; extra == 'docs' + - pillow ; extra == 'docs' + - pycairo ; extra == 'test' + - pytest ; extra == 'test' + - pytest-mock ; extra == 'test' + - streamlit ; extra == 'test' + - streamlit ; extra == 'visual' + - matplotlib ; extra == 'visual' + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl name: mccabe version: 0.7.0 @@ -9865,6 +9894,26 @@ packages: - arabic-reshaper ; extra == 'rtl-text' - python-bidi ; extra == 'rtl-text' requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/3d/de/02f631997741485881fbe129aef2de75a23b4e9776f5b1c4a7afc313ae7e/raygeo-1.62.0.tar.gz + name: raygeo + version: 1.62.0 + sha256: 45b319ba805362aaa288532a0f0b0394b0898f39724c9b6a7ae378c463ed2346 + requires_dist: + - msgpack + - numpy>=1.20.0 + - pycairo ; extra == 'test' + - pytest ; extra == 'test' + - pytest-mock ; extra == 'test' + - streamlit ; extra == 'test' + - streamlit ; extra == 'visual' + - matplotlib ; extra == 'visual' + - matplotlib ; extra == 'docs' + - mdformat ; extra == 'docs' + - mdformat-frontmatter ; extra == 'docs' + - mdformat-tables ; extra == 'docs' + - pillow ; extra == 'docs' + - matplotlib ; extra == 'cli' + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/3f/99/2e119d541d596daea39643eb9312b47c7847383951300f889166938035b1/rpyc-6.0.2-py3-none-any.whl name: rpyc version: 6.0.2 @@ -9904,26 +9953,6 @@ packages: version: 4.0.11 sha256: 1cbdc4ace7fb11d2f6d073f5c3d91537a23b3d58d2bf2fdffc12c6d717a589c6 requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/4b/5c/7bb93dd4c4c755c5bf99e9ffadeb4ec8a6477fd229994fdcbc74e25a76b7/raygeo-1.61.0-cp311-abi3-macosx_11_0_arm64.whl - name: raygeo - version: 1.61.0 - sha256: 69f0683f4405c7fecef3cccc7ff07f5a0375a3b587a4893b609c1ee0cb8de67f - requires_dist: - - msgpack - - numpy>=1.20.0 - - matplotlib ; extra == 'cli' - - matplotlib ; extra == 'docs' - - mdformat ; extra == 'docs' - - mdformat-frontmatter ; extra == 'docs' - - mdformat-tables ; extra == 'docs' - - pillow ; extra == 'docs' - - pycairo ; extra == 'test' - - pytest ; extra == 'test' - - pytest-mock ; extra == 'test' - - streamlit ; extra == 'test' - - streamlit ; extra == 'visual' - - matplotlib ; extra == 'visual' - requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/4f/a7/aeedb5140afa41fc74c225e9184ab96723a6e873b6ee1c9fede7283456d8/markupsafe-3.0.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl name: markupsafe version: 3.0.4 @@ -10619,26 +10648,6 @@ packages: - pytest-asyncio ; extra == 'dev' - tox ; extra == 'dev' requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/dd/e9/fe7809075e9b29f9d938950eefe7a3da84d0f69be540d6dc1aa5a25ef02e/raygeo-1.61.0.tar.gz - name: raygeo - version: 1.61.0 - sha256: e28cbf80230f18001cb8955240176e6a0c3a0070915cffd6a6e2f9509d2cc694 - requires_dist: - - msgpack - - numpy>=1.20.0 - - pycairo ; extra == 'test' - - pytest ; extra == 'test' - - pytest-mock ; extra == 'test' - - streamlit ; extra == 'test' - - streamlit ; extra == 'visual' - - matplotlib ; extra == 'visual' - - matplotlib ; extra == 'docs' - - mdformat ; extra == 'docs' - - mdformat-frontmatter ; extra == 'docs' - - mdformat-tables ; extra == 'docs' - - pillow ; extra == 'docs' - - matplotlib ; extra == 'cli' - requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/df/93/084f10ebbecdedf7902ed68fbecc903f0e9c5742155bfffb05eccbc071da/virtualenv-21.14.5-py3-none-any.whl name: virtualenv version: 21.14.5 diff --git a/pixi.toml b/pixi.toml index f35a20b38..b9c8738b2 100644 --- a/pixi.toml +++ b/pixi.toml @@ -38,7 +38,7 @@ rust = "==1.98.1" patchelf = "==0.19.2" [feature.app.pypi-dependencies] -raygeo = "==1.61.0" +raygeo = "==1.62.0" raydriver = "==0.3.0" rayforge = { editable = true, path = "." } ruida-pa = "==0.22.0" diff --git a/rayforge/image/dither.py b/rayforge/image/dither.py index 83897fb08..63cadf37e 100644 --- a/rayforge/image/dither.py +++ b/rayforge/image/dither.py @@ -1,6 +1,5 @@ """Dithering algorithms for converting grayscale images to binary.""" -import math from enum import Enum from gettext import gettext as _ @@ -8,18 +7,16 @@ from raygeo.image.convert import rgba_to_grayscale from raygeo.image.dither import ( apply_bayer_dither, + apply_error_diffusion_dither, apply_floyd_steinberg_dither, + apply_halftone_dither, apply_minimum_run_length, + apply_newsprint_dither, ) -from raygeo.image.srgb import srgb_to_linear DEFAULT_HALFTONE_CELL_MM = 0.5 DEFAULT_HALFTONE_ANGLE = 45.0 -#: Rows processed per block when building the halftone screen, to -#: bound the memory of the per-pixel coordinate arrays. -_HALFTONE_BLOCK_ROWS = 256 - class DitherAlgorithm(Enum): FLOYD_STEINBERG = "floyd_steinberg" @@ -57,97 +54,21 @@ def display_name(self) -> str: @property def is_error_diffusion(self) -> bool: - return self in ERROR_DIFFUSION_KERNELS - - -#: Error-diffusion kernels as ``(divisor, [(dx, dy, weight), ...])``. -#: ``dx`` is relative to the scan direction, so serpentine rows mirror -#: it. Atkinson deliberately diffuses only 6/8 of the error. -ERROR_DIFFUSION_KERNELS: dict[ - DitherAlgorithm, tuple[int, list[tuple[int, int, int]]] -] = { - DitherAlgorithm.FLOYD_STEINBERG: ( - 16, - [(1, 0, 7), (-1, 1, 3), (0, 1, 5), (1, 1, 1)], - ), - DitherAlgorithm.ATKINSON: ( - 8, - [(1, 0, 1), (2, 0, 1), (-1, 1, 1), (0, 1, 1), (1, 1, 1), (0, 2, 1)], - ), - DitherAlgorithm.STUCKI: ( - 42, - [ - (1, 0, 8), - (2, 0, 4), - (-2, 1, 2), - (-1, 1, 4), - (0, 1, 8), - (1, 1, 4), - (2, 1, 2), - (-2, 2, 1), - (-1, 2, 2), - (0, 2, 4), - (1, 2, 2), - (2, 2, 1), - ], - ), - DitherAlgorithm.JARVIS_JUDICE_NINKE: ( - 48, - [ - (1, 0, 7), - (2, 0, 5), - (-2, 1, 3), - (-1, 1, 5), - (0, 1, 7), - (1, 1, 5), - (2, 1, 3), - (-2, 2, 1), - (-1, 2, 3), - (0, 2, 5), - (1, 2, 3), - (2, 2, 1), - ], - ), - DitherAlgorithm.SIERRA: ( - 32, - [ - (1, 0, 5), - (2, 0, 3), - (-2, 1, 2), - (-1, 1, 4), - (0, 1, 5), - (1, 1, 4), - (2, 1, 2), - (-1, 2, 2), - (0, 2, 3), - (1, 2, 2), - ], - ), - DitherAlgorithm.SIERRA_2ROW: ( - 16, - [ - (1, 0, 4), - (2, 0, 3), - (-2, 1, 1), - (-1, 1, 2), - (0, 1, 3), - (1, 1, 2), - (2, 1, 1), - ], - ), - DitherAlgorithm.SIERRA_LITE: (4, [(1, 0, 2), (-1, 1, 1), (0, 1, 1)]), - DitherAlgorithm.BURKES: ( - 32, - [ - (1, 0, 8), - (2, 0, 4), - (-2, 1, 2), - (-1, 1, 4), - (0, 1, 8), - (1, 1, 4), - (2, 1, 2), - ], - ), + return self in KERNEL_NAMES + + +#: Raygeo kernel names of the error-diffusion algorithms. The Jarvis, +#: Judice & Ninke kernel is spelled out in raygeo while the enum keeps +#: its shorter historical value. +KERNEL_NAMES: dict[DitherAlgorithm, str] = { + DitherAlgorithm.FLOYD_STEINBERG: "floyd_steinberg", + DitherAlgorithm.ATKINSON: "atkinson", + DitherAlgorithm.STUCKI: "stucki", + DitherAlgorithm.JARVIS_JUDICE_NINKE: "jarvis_judice_ninke", + DitherAlgorithm.SIERRA: "sierra", + DitherAlgorithm.SIERRA_2ROW: "sierra_2row", + DitherAlgorithm.SIERRA_LITE: "sierra_lite", + DitherAlgorithm.BURKES: "burkes", } @@ -173,191 +94,6 @@ def is_error_diffusion(self) -> bool: } -def _spot_function(u: np.ndarray, v: np.ndarray) -> np.ndarray: - """Round-dot screen spot function in cell units, 1 at dot centres - and 0 halfway between them.""" - return (np.cos(2.0 * np.pi * u) + np.cos(2.0 * np.pi * v)) / 4.0 + 0.5 - - -def _build_spot_cdf(samples: int = 256) -> np.ndarray: - """Sorted spot values over one cell, used to turn a spot value - into the fraction of the cell it covers.""" - grid = (np.arange(samples) + 0.5) / samples - u, v = np.meshgrid(grid, grid) - return np.sort(_spot_function(u, v).ravel()) - - -def _build_newsprint_matrix(size: int = 8) -> np.ndarray: - """Clustered-dot threshold ranks with the dots on a 45° lattice.""" - grid = (np.arange(size) + 0.5) / size - u, v = np.meshgrid(grid, grid) - spot = _spot_function(u + v, u - v) - order = np.argsort(-spot.ravel(), kind="stable") - ranks = np.empty(size * size, dtype=np.int32) - ranks[order] = np.arange(size * size) - return ranks.reshape(size, size) - - -_SPOT_CDF = _build_spot_cdf() -NEWSPRINT_MATRIX = _build_newsprint_matrix() - - -def _diffuse_row( - row: np.ndarray, - forward: tuple[float, float], - reverse: bool, -) -> tuple[np.ndarray, np.ndarray]: - """Quantise one row, pushing the error to the next one or two - pixels in scan direction. Returns ``(errors, dark)`` in image - order.""" - values = (row[::-1] if reverse else row).tolist() - width = len(values) - values.extend((0.0, 0.0)) - w1, w2 = forward - errors = [0.0] * width - dark = [0] * width - for x in range(width): - value = values[x] - if value < 0.5: - dark[x] = 1 - error = value - else: - error = value - 1.0 - errors[x] = error - values[x + 1] += error * w1 - values[x + 2] += error * w2 - errors_arr = np.asarray(errors) - dark_arr = np.asarray(dark, dtype=np.uint8) - if reverse: - return errors_arr[::-1], dark_arr[::-1] - return errors_arr, dark_arr - - -def apply_error_diffusion( - values: np.ndarray, - kernel: tuple[int, list[tuple[int, int, int]]], - serpentine: bool = False, -) -> np.ndarray: - """ - Error-diffusion dithering with an arbitrary forward kernel. - - The error within a row is propagated pixel by pixel; the error for - the following rows is spread with one vectorised add per kernel - tap once the row is done. - - Args: - values: 2D float array of intensities in [0, 1] (1 = white). - kernel: ``(divisor, [(dx, dy, weight), ...])`` with - ``dy == 0`` taps limited to ``dx`` 1 and 2. - serpentine: Scan odd rows right-to-left, mirroring the kernel. - - Returns: - Binary uint8 array where 1 marks pixels to engrave. - """ - height, width = values.shape - out = np.zeros((height, width), dtype=np.uint8) - if height == 0 or width == 0: - return out - divisor, taps = kernel - forward = [0.0, 0.0, 0.0] - later = [] - for dx, dy, weight in taps: - if dy == 0: - forward[dx] = weight / divisor - else: - later.append((dx, dy, weight / divisor)) - pad = max((abs(dx) for dx, _dy, _w in later), default=0) - extra_rows = max((dy for _dx, dy, _w in later), default=0) - work = np.zeros((height + extra_rows, width + 2 * pad)) - work[:height, pad : pad + width] = values - for y in range(height): - reverse = serpentine and y % 2 == 1 - errors, dark = _diffuse_row( - work[y, pad : pad + width], (forward[1], forward[2]), reverse - ) - out[y] = dark - for dx, dy, weight in later: - start = pad + (-dx if reverse else dx) - work[y + dy, start : start + width] += errors * weight - return out - - -def apply_halftone( - grayscale: np.ndarray, - cell_mm: float, - angle_deg: float, - pixels_per_mm: tuple[float, float], -) -> np.ndarray: - """ - Amplitude-modulated halftone screen with round dots. - - The screen is laid out in millimetres, so the dots stay round on - non-square pixels. Dot area tracks darkness: a pixel is engraved - when its brightness is below the share of the cell that the spot - function ranks at or under it. - - Args: - grayscale: 2D uint8 grayscale array (0-255). - cell_mm: Distance between dot centres in mm. - angle_deg: Screen angle in degrees. - pixels_per_mm: ``(x, y)`` image resolution. - - Returns: - Binary uint8 array where 1 marks pixels to engrave. - """ - height, width = grayscale.shape - ppm_x, ppm_y = pixels_per_mm - if cell_mm <= 0: - cell_mm = 1.0 / max(ppm_x, ppm_y) - theta = math.radians(angle_deg) - cos_t = math.cos(theta) / cell_mm - sin_t = math.sin(theta) / cell_mm - x_mm = (np.arange(width) + 0.5) / ppm_x - out = np.empty((height, width), dtype=np.uint8) - for top in range(0, height, _HALFTONE_BLOCK_ROWS): - bottom = min(height, top + _HALFTONE_BLOCK_ROWS) - y_mm = ((np.arange(top, bottom) + 0.5) / ppm_y)[:, None] - u = x_mm * cos_t + y_mm * sin_t - v = y_mm * cos_t - x_mm * sin_t - spot = _spot_function(u, v) - coverage = (np.searchsorted(_SPOT_CDF, spot) + 0.5) / _SPOT_CDF.size - brightness = grayscale[top:bottom] / 255.0 - out[top:bottom] = brightness < coverage - return out - - -def _apply_newsprint( - grayscale: np.ndarray, - min_feature_px: int, - pixels_per_mm: tuple[float, float], -) -> np.ndarray: - """Tile the clustered-dot matrix with physically square cells.""" - height, width = grayscale.shape - size = NEWSPRINT_MATRIX.shape[0] - ppm_x, ppm_y = pixels_per_mm - cell_x = max(1, min_feature_px) - cell_y = max(1, round(cell_x * ppm_y / ppm_x)) - rows = (np.arange(height) // cell_y) % size - cols = (np.arange(width) // cell_x) % size - ranks = NEWSPRINT_MATRIX[rows[:, None], cols[None, :]] - thresholds = 255.0 * (1.0 - (ranks + 0.5) / (size * size)) - return (grayscale < thresholds).astype(np.uint8) - - -def _error_diffusion_dither( - grayscale: np.ndarray, - dither_algorithm: DitherAlgorithm, - serpentine: bool, -) -> np.ndarray: - """Dither in linear light, like raygeo's Floyd-Steinberg.""" - linear = srgb_to_linear(np.ascontiguousarray(grayscale, dtype=np.uint8)) - return apply_error_diffusion( - linear.astype(np.float64), - ERROR_DIFFUSION_KERNELS[dither_algorithm], - serpentine, - ) - - def grayscale_to_dithered_array( grayscale: np.ndarray, dither_algorithm: DitherAlgorithm, @@ -400,12 +136,14 @@ def grayscale_to_dithered_array( if invert: grayscale = 255 - grayscale if dither_algorithm == DitherAlgorithm.HALFTONE: - return apply_halftone( + return apply_halftone_dither( grayscale, halftone_cell_mm, halftone_angle, pixels_per_mm ) if dither_algorithm == DitherAlgorithm.NEWSPRINT: - return _apply_newsprint(grayscale, min_feature_px, pixels_per_mm) - bw_image = _error_diffusion_dither(grayscale, dither_algorithm, serpentine) + return apply_newsprint_dither(grayscale, min_feature_px, pixels_per_mm) + bw_image = apply_error_diffusion_dither( + grayscale, KERNEL_NAMES[dither_algorithm], serpentine=serpentine + ) return apply_minimum_run_length(bw_image, min_feature_px) diff --git a/requirements.txt b/requirements.txt index 5c03767b4..b46963b07 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ raydriver==0.3.0 -raygeo==1.61.0 +raygeo==1.62.0 aiohttp==3.14.3 asyncudp==0.11.0 blinker==1.9.0 diff --git a/tests/image/test_dither_algorithms.py b/tests/image/test_dither_algorithms.py index 73991d0c8..6f426175d 100644 --- a/tests/image/test_dither_algorithms.py +++ b/tests/image/test_dither_algorithms.py @@ -6,18 +6,13 @@ import pytest from raygeo.image.dither import ( apply_floyd_steinberg_dither, + apply_halftone_dither, apply_minimum_run_length, ) +from raygeo.image.srgb import srgb_to_linear from scipy import ndimage -from rayforge.image.dither import ( - ERROR_DIFFUSION_KERNELS, - DitherAlgorithm, - apply_error_diffusion, - apply_halftone, - grayscale_to_dithered_array, - srgb_to_linear, -) +from rayforge.image.dither import DitherAlgorithm, grayscale_to_dithered_array ERROR_DIFFUSION = [ DitherAlgorithm.FLOYD_STEINBERG, @@ -43,38 +38,11 @@ ] -def _reference_diffusion(values, kernel, serpentine): - """Straightforward per-pixel error diffusion used as an oracle.""" - divisor, taps = kernel - work = values.astype(np.float64).copy() - height, width = work.shape - out = np.zeros((height, width), dtype=np.uint8) - for y in range(height): - reverse = serpentine and y % 2 == 1 - xs = range(width - 1, -1, -1) if reverse else range(width) - for x in xs: - old = work[y, x] - new = 0.0 if old < 0.5 else 1.0 - out[y, x] = 1 if new == 0.0 else 0 - err = old - new - for dx, dy, weight in taps: - tx = x - dx if reverse else x + dx - ty = y + dy - if 0 <= tx < width and ty < height: - work[ty, tx] += err * weight / divisor - return out - - def _label(image: np.ndarray) -> tuple[np.ndarray, int]: """Connected dots of a binary image and their count.""" return cast(tuple[np.ndarray, int], ndimage.label(image)) -def _dyadic_image(rng, shape): - """Values k/16 keep every diffused error exactly representable.""" - return rng.integers(0, 17, shape).astype(np.float64) / 16.0 - - class TestAlgorithmCatalogue: def test_new_algorithms_exist_with_stable_values(self): assert DitherAlgorithm.ATKINSON.value == "atkinson" @@ -95,74 +63,6 @@ def test_error_diffusion_flag(self): for algo in DitherAlgorithm: assert algo.is_error_diffusion == (algo in ERROR_DIFFUSION) - @pytest.mark.parametrize("algo", ERROR_DIFFUSION) - def test_kernel_weights_sum_to_divisor(self, algo): - divisor, taps = ERROR_DIFFUSION_KERNELS[algo] - total = sum(weight for _dx, _dy, weight in taps) - if algo is DitherAlgorithm.ATKINSON: - assert total == 6 and divisor == 8 - else: - assert total == divisor - - @pytest.mark.parametrize("algo", ERROR_DIFFUSION) - def test_kernels_only_push_error_forward(self, algo): - _divisor, taps = ERROR_DIFFUSION_KERNELS[algo] - for dx, dy, _weight in taps: - assert dy > 0 or (dy == 0 and dx > 0) - - def test_stucki_kernel(self): - divisor, taps = ERROR_DIFFUSION_KERNELS[DitherAlgorithm.STUCKI] - assert divisor == 42 - assert sorted(taps) == sorted( - [ - (1, 0, 8), - (2, 0, 4), - (-2, 1, 2), - (-1, 1, 4), - (0, 1, 8), - (1, 1, 4), - (2, 1, 2), - (-2, 2, 1), - (-1, 2, 2), - (0, 2, 4), - (1, 2, 2), - (2, 2, 1), - ] - ) - - -class TestErrorDiffusionCore: - @pytest.mark.parametrize("algo", ERROR_DIFFUSION) - @pytest.mark.parametrize("serpentine", [False, True]) - def test_matches_reference_implementation(self, algo, serpentine): - rng = np.random.default_rng(7) - kernel = ERROR_DIFFUSION_KERNELS[algo] - for shape in [(1, 1), (1, 6), (5, 1), (4, 6), (6, 5)]: - values = _dyadic_image(rng, shape) - expected = _reference_diffusion(values, kernel, serpentine) - result = apply_error_diffusion(values, kernel, serpentine) - np.testing.assert_array_equal(result, expected) - - def test_serpentine_reverses_odd_rows(self): - kernel = ERROR_DIFFUSION_KERNELS[DitherAlgorithm.SIERRA_LITE] - values = np.tile(np.linspace(0.0, 1.0, 8), (4, 1)) - plain = apply_error_diffusion(values, kernel, False) - serpentine = apply_error_diffusion(values, kernel, True) - np.testing.assert_array_equal(plain[:2], serpentine[:2]) - assert not np.array_equal(plain, serpentine) - - def test_does_not_modify_input(self): - kernel = ERROR_DIFFUSION_KERNELS[DitherAlgorithm.STUCKI] - values = np.full((5, 5), 0.3) - original = values.copy() - apply_error_diffusion(values, kernel, True) - np.testing.assert_array_equal(values, original) - - def test_empty_image(self): - kernel = ERROR_DIFFUSION_KERNELS[DitherAlgorithm.STUCKI] - result = apply_error_diffusion(np.zeros((0, 4)), kernel, False) - assert result.shape == (0, 4) - class TestGrayscaleToDitheredArray: @pytest.mark.parametrize("algo", NEW_ALGORITHMS) @@ -239,6 +139,13 @@ def test_floyd_steinberg_serpentine_differs_from_raygeo(self): assert not np.array_equal(plain, serpentine) assert serpentine.mean() == pytest.approx(plain.mean(), abs=0.02) + def test_enum_values_translate_to_raygeo_kernels(self): + """The enum values are not raygeo kernel names (Jarvis, Judice + & Ninke in particular); the dispatch must translate them.""" + img = np.full((16, 16), 100, dtype=np.uint8) + for algo in ERROR_DIFFUSION: + assert grayscale_to_dithered_array(img, algo).shape == (16, 16) + @pytest.mark.parametrize("algo", ERROR_DIFFUSION[1:]) def test_minimum_feature_size_removes_short_runs(self, algo): img = np.full((32, 64), 200, dtype=np.uint8) @@ -259,12 +166,19 @@ def test_serpentine_is_ignored_by_screens(self): class TestHalftone: + def _halftone(self, img, cell_mm, angle_deg, pixels_per_mm): + return grayscale_to_dithered_array( + img, + DitherAlgorithm.HALFTONE, + halftone_cell_mm=cell_mm, + halftone_angle=angle_deg, + pixels_per_mm=pixels_per_mm, + ) + @pytest.mark.parametrize("gray", [30, 100, 160, 220]) def test_coverage_follows_darkness(self, gray): img = np.full((200, 200), gray, dtype=np.uint8) - result = apply_halftone( - img, cell_mm=1.0, angle_deg=45.0, pixels_per_mm=(10.0, 10.0) - ) + result = self._halftone(img, 1.0, 45.0, (10.0, 10.0)) assert result.mean() == pytest.approx(1.0 - gray / 255.0, abs=0.03) def test_dot_count_follows_cell_size(self): @@ -272,12 +186,7 @@ def test_dot_count_follows_cell_size(self): a 20 mm square holds 21 x 21 (partly clipped) dots at 1 mm.""" img = np.full((200, 200), 230, dtype=np.uint8) for cell_mm, expected in ((1.0, 441), (2.0, 121)): - result = apply_halftone( - img, - cell_mm=cell_mm, - angle_deg=0.0, - pixels_per_mm=(10.0, 10.0), - ) + result = self._halftone(img, cell_mm, 0.0, (10.0, 10.0)) _labels, count = _label(result) assert count == pytest.approx(expected, rel=0.1) @@ -285,9 +194,7 @@ def test_dots_are_round_on_anisotropic_pixels(self): """X is sampled twice as densely as Y; the dots must still be round in millimetres, so twice as wide in pixels.""" img = np.full((100, 200), 220, dtype=np.uint8) - result = apply_halftone( - img, cell_mm=2.0, angle_deg=0.0, pixels_per_mm=(20.0, 10.0) - ) + result = self._halftone(img, 2.0, 0.0, (20.0, 10.0)) labels, _count = _label(result) boxes = ndimage.find_objects(labels) interior = [ @@ -306,32 +213,23 @@ def test_dots_are_round_on_anisotropic_pixels(self): def test_angle_rotates_the_screen(self): img = np.full((120, 120), 200, dtype=np.uint8) - kwargs = {"cell_mm": 1.0, "pixels_per_mm": (10.0, 10.0)} - straight = apply_halftone(img, angle_deg=0.0, **kwargs) - rotated = apply_halftone(img, angle_deg=45.0, **kwargs) + straight = self._halftone(img, 1.0, 0.0, (10.0, 10.0)) + rotated = self._halftone(img, 1.0, 45.0, (10.0, 10.0)) assert not np.array_equal(straight, rotated) assert rotated.mean() == pytest.approx(straight.mean(), abs=0.03) def test_invalid_cell_size_falls_back_to_one_pixel_cells(self): img = np.full((10, 10), 0, dtype=np.uint8) - result = apply_halftone( - img, cell_mm=0.0, angle_deg=0.0, pixels_per_mm=(10.0, 10.0) - ) + result = self._halftone(img, 0.0, 0.0, (10.0, 10.0)) assert result.all() - def test_dispatch_through_grayscale_to_dithered_array(self): + def test_dispatch_forwards_the_screen_settings_to_raygeo(self): img = np.full((100, 100), 128, dtype=np.uint8) - direct = apply_halftone( - img, cell_mm=0.8, angle_deg=30.0, pixels_per_mm=(10.0, 10.0) - ) - dispatched = grayscale_to_dithered_array( - img, - DitherAlgorithm.HALFTONE, - halftone_cell_mm=0.8, - halftone_angle=30.0, - pixels_per_mm=(10.0, 10.0), + dispatched = self._halftone(img, 0.8, 30.0, (10.0, 10.0)) + expected = apply_halftone_dither( + img, 0.8, 30.0, pixels_per_mm=(10.0, 10.0) ) - np.testing.assert_array_equal(direct, dispatched) + np.testing.assert_array_equal(dispatched, expected) class TestNewsprint: