diff --git a/CHANGELOG.md b/CHANGELOG.md index 283c89fe6..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 @@ -55,6 +61,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/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/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/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 c5c44e9bd..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 ( @@ -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,82 @@ 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 +): + """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" + + +@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/image/dither.py b/rayforge/image/dither.py index 1a0d64881..63cadf37e 100644 --- a/rayforge/image/dither.py +++ b/rayforge/image/dither.py @@ -7,27 +7,70 @@ 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, ) +DEFAULT_HALFTONE_CELL_MM = 0.5 +DEFAULT_HALFTONE_ANGLE = 45.0 + 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 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", +} + BAYER_MATRICES = { DitherAlgorithm.BAYER2: np.array([[0, 2], [3, 1]], dtype=np.float32), @@ -56,6 +99,11 @@ def grayscale_to_dithered_array( 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 +113,38 @@ 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_dither( + grayscale, halftone_cell_mm, halftone_angle, pixels_per_mm + ) + if dither_algorithm == DitherAlgorithm.NEWSPRINT: + 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) def surface_to_dithered_array( 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/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/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() 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/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 diff --git a/tests/image/test_dither_algorithms.py b/tests/image/test_dither_algorithms.py new file mode 100644 index 000000000..6f426175d --- /dev/null +++ b/tests/image/test_dither_algorithms.py @@ -0,0 +1,261 @@ +"""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_halftone_dither, + apply_minimum_run_length, +) +from raygeo.image.srgb import srgb_to_linear +from scipy import ndimage + +from rayforge.image.dither import DitherAlgorithm, grayscale_to_dithered_array + +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 _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)) + + +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) + + +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) + + 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) + 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: + 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 = 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): + """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 = self._halftone(img, cell_mm, 0.0, (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 = self._halftone(img, 2.0, 0.0, (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) + 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 = self._halftone(img, 0.0, 0.0, (10.0, 10.0)) + assert result.all() + + def test_dispatch_forwards_the_screen_settings_to_raygeo(self): + img = np.full((100, 100), 128, dtype=np.uint8) + 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(dispatched, expected) + + +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 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( 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)