pax_global_header00006660000000000000000000000064147414100730014513gustar00rootroot0000000000000052 comment=7b25b5324cabf00ed821018992ff7730abb4f589 napari-svg-0.2.1/000077500000000000000000000000001474141007300135625ustar00rootroot00000000000000napari-svg-0.2.1/.cruft.json000066400000000000000000000013531474141007300156600ustar00rootroot00000000000000{ "template": "https://github.com/napari/cookiecutter-napari-plugin", "commit": "b89b0524037227b113e6aa512354afb698400e3f", "context": { "cookiecutter": { "full_name": "Nicholas Sofroniew", "email": "sofroniewn@gmail.com", "github_username": "napari", "plugin_name": "svg", "module_name": "napari_svg", "short_description": "A plugin for writing svg files with napari", "include_reader_plugin": "n", "include_writer_plugin": "y", "include_dock_widget_plugin": "n", "include_function_plugin": "n", "docs_tool": "none", "license": "BSD-3", "_template": "https://github.com/napari/cookiecutter-napari-plugin" } }, "directory": null, "checkout": null } napari-svg-0.2.1/.github/000077500000000000000000000000001474141007300151225ustar00rootroot00000000000000napari-svg-0.2.1/.github/workflows/000077500000000000000000000000001474141007300171575ustar00rootroot00000000000000napari-svg-0.2.1/.github/workflows/test_and_deploy.yml000066400000000000000000000062641474141007300230670ustar00rootroot00000000000000# This workflows will upload a Python Package using Twine when a release is created # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries name: tests on: push: branches: - master - main tags: - "v*" # Push events to matching v*, i.e. v1.0, v20.15.10 pull_request: branches: - master - main workflow_dispatch: jobs: test: name: ${{ matrix.platform }} py${{ matrix.python-version }} runs-on: ${{ matrix.platform }} strategy: matrix: platform: [ubuntu-latest, windows-latest, macos-latest] python-version: ["3.9", "3.12"] steps: - uses: actions/checkout@v3 with: fetch-depth: 0 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} # these libraries, along with pytest-xvfb (added in the `deps` in tox.ini), # enable testing on Qt on linux - name: Install Linux libraries if: runner.os == 'Linux' run: | sudo apt-get install -y libdbus-1-3 libxkbcommon-x11-0 libxcb-icccm4 \ libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 \ libxcb-xinerama0 libxcb-xinput0 libxcb-xfixes0 # strategy borrowed from vispy for installing opengl libs on windows - name: Install Windows OpenGL if: runner.os == 'Windows' uses: actions/checkout@v3 with: path: gl-ci-helpers repository: pyvista/gl-ci-helpers fetch-depth: 1 - name: Install Windows OpenGL if: runner.os == 'Windows' run: | powershell gl-ci-helpers/appveyor/install_opengl.ps1 # note: if you need dependencies from conda, considering using # setup-miniconda: https://github.com/conda-incubator/setup-miniconda # and # tox-conda: https://github.com/tox-dev/tox-conda - name: Install dependencies run: | python -m pip install --upgrade pip pip install setuptools tox tox-gh-actions # this runs the platform-specific tests declared in tox.ini - name: Test with tox run: tox env: PLATFORM: ${{ matrix.platform }} - name: Coverage uses: codecov/codecov-action@v1 deploy: # this will run when you have tagged a commit, starting with "v*" # and requires that you have put your twine API key in your # github secrets (see readme for details) needs: [test] runs-on: ubuntu-latest if: contains(github.ref, 'tags') permissions: id-token: write steps: - uses: actions/checkout@v2 - name: Set up Python uses: actions/setup-python@v2 with: python-version: "3.x" - name: Install dependencies run: | python -m pip install --upgrade pip pip install -U setuptools setuptools_scm wheel build twine - name: Build run: | git tag python -m build twine check dist/* - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 napari-svg-0.2.1/.gitignore000066400000000000000000000016631474141007300155600ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python env/ build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ *.egg-info/ .installed.cfg *.egg # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *,cover .hypothesis/ .napari_cache # Translations *.mo *.pot # Django stuff: *.log local_settings.py # Flask instance folder instance/ # Sphinx documentation docs/_build/ # MkDocs documentation /site/ # PyBuilder target/ # IPython Notebook .ipynb_checkpoints # pyenv .python-version # OS .DS_Store # written by setuptools_scm */_version.py napari-svg-0.2.1/LICENSE000066400000000000000000000027171474141007300145760ustar00rootroot00000000000000 Copyright (c) 2020, Nicholas Sofroniew All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of napari-svg nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. napari-svg-0.2.1/MANIFEST.in000066400000000000000000000002371474141007300153220ustar00rootroot00000000000000include LICENSE include README.md recursive-include requirements/ *.txt include requirements.txt recursive-exclude * __pycache__ recursive-exclude * *.py[co] napari-svg-0.2.1/README.md000066400000000000000000000043701474141007300150450ustar00rootroot00000000000000# napari-svg [![License](https://img.shields.io/pypi/l/napari-svg.svg?color=green)](https://github.com/napari/napari-svg/raw/master/LICENSE) [![PyPI](https://img.shields.io/pypi/v/napari-svg.svg?color=green)](https://pypi.org/project/napari-svg) [![Python Version](https://img.shields.io/pypi/pyversions/napari-svg.svg?color=green)](https://python.org) [![tests](https://github.com/napari/napari-svg/workflows/tests/badge.svg)](https://github.com/napari/napari-svg/actions) [![codecov](https://codecov.io/gh/napari/napari-svg/branch/master/graph/badge.svg)](https://codecov.io/gh/napari/napari-svg) A plugin for writing svg files with napari ---------------------------------- This [napari] plugin was generated with [Cookiecutter] using with [@napari]'s [cookiecutter-napari-plugin] template. ## Installation You can install `napari-svg` via [pip]: pip install napari-svg ## Contributing Contributions are very welcome. Tests can be run with [tox], please ensure the coverage at least stays the same before you submit a pull request. ## License Distributed under the terms of the [BSD-3] license, "napari-svg" is free and open source software ## Issues If you encounter any problems, please [file an issue] along with a detailed description. [napari]: https://github.com/napari/napari [Cookiecutter]: https://github.com/audreyr/cookiecutter [@napari]: https://github.com/napari [MIT]: http://opensource.org/licenses/MIT [BSD-3]: http://opensource.org/licenses/BSD-3-Clause [GNU GPL v3.0]: http://www.gnu.org/licenses/gpl-3.0.txt [GNU LGPL v3.0]: http://www.gnu.org/licenses/lgpl-3.0.txt [Apache Software License 2.0]: http://www.apache.org/licenses/LICENSE-2.0 [Mozilla Public License 2.0]: https://www.mozilla.org/media/MPL/2.0/index.txt [cookiecutter-napari-plugin]: https://github.com/napari/cookiecutter-napari-plugin [file an issue]: https://github.com/napari/napari-svg/issues [napari]: https://github.com/napari/napari [tox]: https://tox.readthedocs.io/en/latest/ [pip]: https://pypi.org/project/pip/ [PyPI]: https://pypi.org/ napari-svg-0.2.1/docs/000077500000000000000000000000001474141007300145125ustar00rootroot00000000000000napari-svg-0.2.1/docs/index.md000066400000000000000000000001101474141007300161330ustar00rootroot00000000000000# Welcome to napari-svg An io plugin for writing svg files with napari napari-svg-0.2.1/mkdocs.yml000066400000000000000000000003301474141007300155610ustar00rootroot00000000000000site_name: napari-svg site_description: An io plugin for writing svg files with napari site_author: Nicholas Sofroniew theme: readthedocs repo_url: https://github.com/sofroniewn/napari-svg pages: - Home: index.md napari-svg-0.2.1/napari_svg/000077500000000000000000000000001474141007300157135ustar00rootroot00000000000000napari-svg-0.2.1/napari_svg/__init__.py000066400000000000000000000004511474141007300200240ustar00rootroot00000000000000try: from ._version import version as __version__ except ImportError: __version__ = "not-installed" from .hook_implementations import ( napari_get_writer, napari_write_image, napari_write_labels, napari_write_points, napari_write_shapes, napari_write_vectors, ) napari-svg-0.2.1/napari_svg/_shape_to_xml.py000066400000000000000000000117171474141007300211150ustar00rootroot00000000000000from xml.etree.ElementTree import Element import numpy as np def ellipse_to_xml(data, svg_props): """Generates an xml element for an ellipse. Only two dimensional ellipses are supported. Parameters ---------- data : (4, 2) array Ellipse vertices. Only two dimensional ellipses data are supported. svg_props : dict svg properties for a shape. Returns ------- element : xml.etree.ElementTree.Element xml element defining an ellipse. """ if data.shape != (4, 2): raise ValueError('Ellipse must be 2 dimensional to save as svg') data = data[:, ::-1] offset = data[1] - data[0] angle = -np.arctan2(offset[0], -offset[1]) if not angle == 0: # if shape has been rotated, shift to origin cen = data.mean(axis=0) coords = data - cen # rotate back to axis aligned c, s = np.cos(angle), np.sin(-angle) rotation = np.array([[c, s], [-s, c]]) coords = coords @ rotation.T # shift back to center coords = coords + cen # define rotation around center transform = f'rotate({np.degrees(-angle)} {cen[0]} {cen[1]})' svg_props['transform'] = transform else: coords = data cx = str(cen[0]) cy = str(cen[1]) size = abs(coords[2] - coords[0]) rx = str(size[0] / 2) ry = str(size[1] / 2) element = Element('ellipse', cx=cx, cy=cy, rx=rx, ry=ry, **svg_props) return element def line_to_xml(data, svg_props): """Generates an xml element for a line. Only two dimensional lines are supported. Parameters ---------- data : (2, 2) array Line vertices. Only two dimensional lines data are supported. svg_props : dict svg properties for a shape. Returns ------- element : xml.etree.ElementTree.Element xml element defining a line. """ if data.shape != (2, 2): raise ValueError('Line must be 2 dimensional to save as svg') x1 = str(data[0, 0]) y1 = str(data[0, 1]) x2 = str(data[1, 0]) y2 = str(data[1, 1]) element = Element('line', x1=y1, y1=x1, x2=y2, y2=x2, **svg_props) return element def path_to_xml(data, svg_props): """Generates an xml element for a path. Only two dimensional paths are supported. Parameters ---------- data : (N, 2) array Path vertices. Only two dimensional paths data are supported. svg_props : dict svg properties for a shape. Returns ------- element : xml.etree.ElementTree.Element xml element defining a polyline. """ if data.shape[1] != 2: raise ValueError('Path must be 2 dimensional to save as svg') points = ' '.join([f'{d[1]},{d[0]}' for d in data]) svg_props['fill'] = 'none' element = Element('polyline', points=points, **svg_props) return element def polygon_to_xml(data, svg_props): """Generates an xml element for a polygon. Only two dimensional polygons are supported. Parameters ---------- data : (N, 2) array Polygon vertices. Only two dimensional polygons data are supported. svg_props : dict svg properties for a shape. Returns ------- element : xml.etree.ElementTree.Element xml element defining a polygon. """ if data.shape[1] != 2: raise ValueError('Polygon must be 2 dimensional to save as svg') points = ' '.join([f'{d[1]},{d[0]}' for d in data]) element = Element('polygon', points=points, **svg_props) return element def rectangle_to_xml(data, svg_props): """Generates an xml element for a rectangle. Only two dimensional rectangles are supported. Parameters ---------- data : (N, 2) array Rectangle vertices. Only two dimensional rectangles data are supported. svg_props : dict svg properties for a shape. Returns ------- element : xml.etree.ElementTree.Element xml element defining a rect. """ if data.shape != (4, 2): raise ValueError('Rectangle must be 2 dimensional to save as svg') data = data[:, ::-1] offset = data[1] - data[0] angle = -np.arctan2(offset[0], -offset[1]) if not angle == 0: # if shape has been rotated, shift to origin cen = data.mean(axis=0) coords = data - cen # rotate back to axis aligned c, s = np.cos(angle), np.sin(-angle) rotation = np.array([[c, s], [-s, c]]) coords = coords @ rotation.T # shift back to center coords = coords + cen # define rotation around center transform = f'rotate({np.degrees(-angle)} {cen[0]} {cen[1]})' svg_props['transform'] = transform else: coords = data x = str(coords.min(axis=0)[0]) y = str(coords.min(axis=0)[1]) size = abs(coords[2] - coords[0]) width = str(size[0]) height = str(size[1]) element = Element( 'rect', x=x, y=y, width=width, height=height, **svg_props ) return element napari-svg-0.2.1/napari_svg/_tests/000077500000000000000000000000001474141007300172145ustar00rootroot00000000000000napari-svg-0.2.1/napari_svg/_tests/__init__.py000066400000000000000000000000001474141007300213130ustar00rootroot00000000000000napari-svg-0.2.1/napari_svg/_tests/test_get_writer.py000066400000000000000000000045441474141007300230070ustar00rootroot00000000000000import os import numpy as np import pytest from napari_svg import napari_get_writer from napari.layers import Image, Labels, Points, Shapes, Vectors @pytest.fixture def layer_data_and_types(): np.random.seed(0) layers = [ Image(np.random.rand(20, 20)), Labels(np.random.randint(10, size=(20, 2))), Points(np.random.rand(20, 2)), Shapes(np.random.rand(10, 2, 2)), Vectors(np.random.rand(10, 2, 2)), ] layer_data = [l.as_layer_data_tuple() for l in layers] layer_types = [ld[2] for ld in layer_data] return layer_data, layer_types def test_get_writer(tmpdir, layer_data_and_types): """Test writing layers data.""" layer_data, layer_types = layer_data_and_types path = os.path.join(tmpdir, 'layers_file.svg') writer = napari_get_writer(path, layer_types) assert writer is not None # Check file does not exist assert not os.path.isfile(path) # Write data return_path = writer(path, layer_data) assert return_path == path # Check file now exists assert os.path.isfile(path) def test_get_writer_no_extension(tmpdir, layer_data_and_types): """Test writing layers data with no extension.""" layer_data, layer_types = layer_data_and_types path = os.path.join(tmpdir, 'layers_file') writer = napari_get_writer(path, layer_types) assert writer is not None # Check file does not exist assert not os.path.isfile(path) # Write data return_path = writer(path, layer_data) assert return_path == path + '.svg' # Check file now exists assert os.path.isfile(path + '.svg') def test_get_writer_bad_extension(tmpdir, layer_data_and_types): """Test not writing layers data with bad extension.""" layer_data, layer_types = layer_data_and_types path = os.path.join(tmpdir, 'layers_file.csv') writer = napari_get_writer(path, layer_types) assert writer is None # Check file does not exist assert not os.path.isfile(path) def test_get_writer_bad_layer_types(tmpdir): """Test not writing layers data with bad extension.""" layer_types = ['image', 'points', 'bad_type'] path = os.path.join(tmpdir, 'layers_file.svg') writer = napari_get_writer(path, layer_types) assert writer is None # Check file does not exist assert not os.path.isfile(path) napari-svg-0.2.1/napari_svg/_tests/test_write_layer.py000066400000000000000000000166131474141007300231620ustar00rootroot00000000000000import os import numpy as np import pytest from pathlib import Path from napari.layers import Image, Points, Labels, Shapes, Vectors from napari.utils.colormaps.colormap_utils import ensure_colormap from napari_svg.layer_to_xml import layer_transforms_to_xml_string from napari_svg import ( napari_write_image, napari_write_labels, napari_write_points, napari_write_shapes, napari_write_vectors, ) from napari_svg.layer_to_xml import _ensure_colormap @pytest.fixture(params=['image', 'labels', 'points', 'shapes', 'shapes-rectangles', 'vectors']) def layer_writer_and_data(request): meta_required = False if request.param == 'image': data = np.random.rand(20, 20) layer = Image(data) writer = napari_write_image elif request.param == 'labels': data = np.random.randint(10, size=(20, 20)) layer = Labels(data) writer = napari_write_labels elif request.param == 'points': data = np.random.rand(20, 2) layer = Points(data) writer = napari_write_points elif request.param == 'shapes': np.random.seed(0) data = [ np.random.rand(2, 2), np.random.rand(2, 2), np.random.rand(6, 2), np.random.rand(6, 2), np.random.rand(2, 2), ] shape_type = ['ellipse', 'line', 'path', 'polygon', 'rectangle'] layer = Shapes(data, shape_type=shape_type) writer = napari_write_shapes meta_required = True elif request.param == 'shapes-rectangles': np.random.seed(0) data = np.random.rand(7, 4, 2) layer = Shapes(data) writer = napari_write_shapes elif request.param == 'vectors': data = np.random.rand(20, 2, 2) layer = Vectors(data) writer = napari_write_vectors else: return None, None, False layer_data = layer.as_layer_data_tuple() return writer, layer_data, meta_required def test_write_layer_no_metadata(tmpdir, layer_writer_and_data): """Test writing layer data with no metadata.""" writer, layer_data, meta_required = layer_writer_and_data if meta_required: return path = os.path.join(tmpdir, 'layer_file.svg') # Check file does not exist assert not os.path.isfile(path) # Write data return_path = writer(path, layer_data[0], {}) assert return_path == path # Check file now exists assert os.path.isfile(path) def test_write_image_from_napari_layer_data(tmpdir, layer_writer_and_data): """Test writing layer data from napari layer_data tuple.""" writer, layer_data, _ = layer_writer_and_data path = os.path.join(tmpdir, 'layer_file.svg') # Check file does not exist assert not os.path.isfile(path) # Write data return_path = writer(path, layer_data[0], layer_data[1]) assert return_path == path # Check file now exists assert os.path.isfile(path) def test_write_rgb_image(tmp_path): """Check that an rgb image can be correctly saved as svg.""" rng = np.random.default_rng(0) image = np.random.randint(0, 256, size=(4, 4, 3), dtype=np.uint8) path = tmp_path / 'rgb.svg' return_path = napari_write_image(str(path), image, {'rgb': True}) assert str(path) == return_path svg_txt = path.read_text() assert 'data:image/png;base64' in svg_txt # start and end of correct base64 encoding for this image assert 'iVBOR' in svg_txt assert 'uQmCC' in svg_txt def test_write_image_no_extension(tmpdir, layer_writer_and_data): """Test writing layer data with no extension.""" writer, layer_data, _ = layer_writer_and_data path = os.path.join(tmpdir, 'layer_file') # Check file does not exist assert not os.path.isfile(path) # Write data return_path = writer(path, layer_data[0], layer_data[1]) assert return_path == path + '.svg' # Check file now exists with an svg extension assert os.path.isfile(path + '.svg') def test_no_write_image_bad_extension(tmpdir, layer_writer_and_data): """Test not writing layer data with a bad extension.""" writer, layer_data, _ = layer_writer_and_data path = os.path.join(tmpdir, 'layer_file.csv') # Check file does not exist assert not os.path.isfile(path) # Check no data is writen return_path = writer(path, layer_data[0], layer_data[1]) assert return_path is None # Check file still does not exist assert not os.path.isfile(path) @pytest.mark.parametrize('colormap', ('viridis', ensure_colormap('viridis').dict())) def test_write_image_colormaps(tmpdir, layer_writer_and_data, colormap): writer, layer_data, _ = layer_writer_and_data layer_data[1]['colormap'] = colormap path = os.path.join(tmpdir, 'layer_file.svg') # Check file does not exist assert not os.path.isfile(path) # Write data return_path = writer(path, layer_data[0], layer_data[1]) assert return_path == path # Check file now exists assert os.path.isfile(path) @pytest.mark.parametrize("path_ensure", [True, False]) def test_write_image_colormaps_vispy(tmpdir, layer_writer_and_data, path_ensure, monkeypatch): if path_ensure: monkeypatch.setattr("napari_svg.layer_to_xml.ensure_colormap", _ensure_colormap) writer, layer_data, _ = layer_writer_and_data layer_data[1]['colormap'] = "autumn" path = os.path.join(tmpdir, 'layer_file.svg') # Check file does not exist assert not os.path.isfile(path) # Write data return_path = writer(path, layer_data[0], layer_data[1]) assert return_path == path # Check file now exists assert os.path.isfile(path) NOOP_TRANSFORM = layer_transforms_to_xml_string({ 'scale': [1.0, 1.0], 'translate': [0.0, 0.0], 'shear': [0.0], 'rotate': [[1.0, 0.0], [0.0, 1.0]], 'affine': [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], }) NOOP_TRANSFORM_STR = f' transform="{NOOP_TRANSFORM}"' def test_write_points_with_attributes(request, tmp_path): data = [ [0, 0], [0, 128], [128, 128], ] size = [16, 20, 24] face_color = ['red', 'green', 'blue'] edge_color = ['cyan', 'magenta', 'yellow'] edge_width = [0.05, 0.25, 0.5] layer = Points( data, opacity=0.5, size=size, face_color=face_color, edge_color=edge_color, edge_width=edge_width, edge_width_is_relative=True, ) test_name = request.node.name path = tmp_path / f'{test_name}-actual.svg' layer_data, layer_attrs, _ = layer.as_layer_data_tuple() return_path = napari_write_points(path, layer_data, layer_attrs) assert return_path == path expected_path = Path(__file__).parent / f'{test_name}-expected.svg' actual_text = path.read_text().replace(NOOP_TRANSFORM_STR, '') expected_text = expected_path.read_text().replace(NOOP_TRANSFORM_STR, '') assert actual_text == expected_text def test_write_points_with_text(tmp_path): data = [ [0, 0], [0, 128], [128, 128], ] text_data = ['car', 'crosswalk', 'bicycle'] layer = Points(data, text=text_data) layer_data, layer_attrs, _ = layer.as_layer_data_tuple() path = tmp_path / 'points-with-text.svg' return_path = napari_write_points(path, layer_data, layer_attrs) assert return_path == path svg_text = path.read_text().replace(NOOP_TRANSFORM_STR, '') for elem in text_data: assert elem in svg_text napari-svg-0.2.1/napari_svg/_tests/test_write_points_with_attributes-expected.svg000066400000000000000000000013751474141007300306300ustar00rootroot00000000000000 napari-svg-0.2.1/napari_svg/hook_implementations.py000066400000000000000000000214561474141007300225250ustar00rootroot00000000000000import os import numpy as np import warnings from .xml_to_svg import xml_to_svg from .layer_to_xml import ( image_to_xml, points_to_xml, shapes_to_xml, vectors_to_xml, ) labels_to_xml = image_to_xml supported_layers = ['image', 'points', 'labels', 'shapes', 'vectors'] def napari_get_writer(path, layer_types): """Write layer data to an svg. Only two dimensional data is supported. Parameters ---------- path : str Path to file, directory, or resource (like a URL). layer_types : list of str List of layer types that will be provided to the writer. Layer types must be contained in the list of currently supported layers. Returns ------- callable function that accepts the path and a list of layer_data (where layer_data is ``(data, meta, layer_type)``) and writes each layer. """ # Check that only supported layers have been passed for lt in set(layer_types): if lt not in supported_layers: return None ext = os.path.splitext(path)[1] if ext == '': path = path + '.svg' elif ext != '.svg': # If an extension is provided then it must be `.svg` return None return writer def writer(path, layer_data): """Write layer data to an svg. Parameters ---------- path : str path to file/directory layer_data : list of napari.types.LayerData List of layer_data, where layer_data is ``(data, meta, layer_type)``. Returns ------- path : str or None If data is successfully written, return the ``path`` that was written. Otherwise, if nothing was done, return ``None``. """ ext = os.path.splitext(path)[1] if ext == '': path = path + '.svg' elif ext != '.svg': # If an extension is provided then it must be `.svg` return None if len(layer_data) == 0: return None # Generate xml list and data extrema for all layers full_xml_list = [] full_extrema = None for ld in layer_data: function_string = ld[2] + '_to_xml(ld[0], ld[1])' layer_xml, extrema = eval(function_string) full_xml_list.append(layer_xml) if full_extrema is None: full_extrema = extrema else: # get the extreema of all elements. with warnings.catch_warnings(): # Taking the nanmin and nanmax of an axis of all nan # raises a warning and returns nan for that axis # as we have do an explict nan_to_num in xml_to_svg this # behaviour is acceptable and we can filter the # warning, see https://github.com/napari/napari-svg/pull/12 warnings.filterwarnings( 'ignore', message='All-NaN axis encountered' ) full_extrema = np.array([ np.nanmin([full_extrema[0], extrema[0]], axis=0), np.nanmax([full_extrema[1], extrema[1]], axis=0), ]) # Generate svg string svg = xml_to_svg(full_xml_list, extrema=full_extrema) # Write svg string with open(path, 'w') as file: file.write(svg) return path def napari_write_image(path, data, meta): """Write image data to an svg. Only two dimensional image data is supported (including rgb, and multiscale). For multiscale data the lowest resolution is used. Parameters ---------- path : str Path to file, directory, or resource (like a URL). data : array or list of array Image data. Only two dimensional data (including rgb, and multiscale) is supported. For multiscale data the lowest resolution is used. meta : dict Image metadata. Returns ------- path : str or None If data is successfully written, return the ``path`` that was written. Otherwise, if nothing was done, return ``None``. """ ext = os.path.splitext(path)[1] if ext == '': path = path + '.svg' elif ext != '.svg': # If an extension is provided then it must be `.svg` return None # Generate xml list and data extrema layer_xml, extrema = image_to_xml(data, meta) # Generate svg string svg = xml_to_svg([layer_xml], extrema=extrema) # Write svg string with open(path, 'w') as file: file.write(svg) return path def napari_write_labels(path, data, meta): """Write labels data to an svg. Only two dimensional labels data is supported (including multiscale). For multiscale data the lowest resolution is used. Parameters ---------- path : str Path to file, directory, or resource (like a URL). data : array or list of array Labels data. Only two dimensional data (including multiscale) is supported. For multiscale data the lowest resolution is used. meta : dict Labels metadata. Returns ------- path : str or None If data is successfully written, return the ``path`` that was written. Otherwise, if nothing was done, return ``None``. """ ext = os.path.splitext(path)[1] if ext == '': path = path + '.svg' elif ext != '.svg': # If an extension is provided then it must be `.svg` return None # Generate xml list and data extrema layer_xml, extrema = labels_to_xml(data, meta) # Generate svg string svg = xml_to_svg([layer_xml], extrema=extrema) # Write svg string with open(path, 'w') as file: file.write(svg) return path def napari_write_points(path, data, meta): """Write points data to an svg. Only two dimensional points data is supported. Z ordering of the points will be taken into account. Each point is represented by a circle. Support for other symbols is not yet implemented. Parameters ---------- path : str Path to file, directory, or resource (like a URL). data : array Points data. Only two dimensional data is supported. meta : dict Points metadata. Returns ------- path : str or None If data is successfully written, return the ``path`` that was written. Otherwise, if nothing was done, return ``None``. """ ext = os.path.splitext(path)[1] if ext == '': path = path + '.svg' elif ext != '.svg': # If an extension is provided then it must be `.svg` return None # Generate xml list and data extrema layer_xml, extrema = points_to_xml(data, meta) # Generate svg string svg = xml_to_svg([layer_xml], extrema=extrema) # Write svg string with open(path, 'w') as file: file.write(svg) return path def napari_write_shapes(path, data, meta): """Write shapes data to an svg. Only two dimensional shapes data is supported. Z ordering of the shapes will be taken into account. Parameters ---------- path : str Path to file, directory, or resource (like a URL). data : list of array Shapes data. Only two dimensional data is supported. meta : dict Shapes metadata. Returns ------- path : str or None If data is successfully written, return the ``path`` that was written. Otherwise, if nothing was done, return ``None``. """ ext = os.path.splitext(path)[1] if ext == '': path = path + '.svg' elif ext != '.svg': # If an extension is provided then it must be `.svg` return None # Generate xml list and data extrema layer_xml, extrema = shapes_to_xml(data, meta) # Generate svg string svg = xml_to_svg([layer_xml], extrema=extrema) # Write svg string with open(path, 'w') as file: file.write(svg) return path def napari_write_vectors(path, data, meta): """Write vectors data to an svg. Only two dimensional vectors data is supported. Each vector is represented by a line. Parameters ---------- path : str Path to file, directory, or resource (like a URL). data : array Vectors data. Only two dimensional data is supported. meta : dict Vectors metadata. Returns ------- path : str or None If data is successfully written, return the ``path`` that was written. Otherwise, if nothing was done, return ``None``. """ ext = os.path.splitext(path)[1] if ext == '': path = path + '.svg' elif ext != '.svg': # If an extension is provided then it must be `.svg` return None # Generate xml list and data extrema layer_xml, extrema = vectors_to_xml(data, meta) # Generate svg string svg = xml_to_svg([layer_xml], extrema=extrema) # Write svg string with open(path, 'w') as file: file.write(svg) return path napari-svg-0.2.1/napari_svg/layer_to_xml.py000066400000000000000000000373771474141007300210040ustar00rootroot00000000000000from xml.etree.ElementTree import Element from base64 import b64encode import numpy as np from copy import copy from imageio import imwrite try: from napari.utils.colormaps.colormap_utils import ensure_colormap except ImportError: # pragma: no cover def ensure_colormap(cmap): return _ensure_colormap(cmap) def _ensure_colormap(cmap): from vispy.color import get_colormap cmap_ = get_colormap(cmap) class CmapWrap: def __init__(self, cmap): self._cmap = cmap def map(self, image): return self._cmap[image].RGBA/255 return CmapWrap(cmap_) from ._shape_to_xml import ( ellipse_to_xml, line_to_xml, path_to_xml, polygon_to_xml, rectangle_to_xml, ) shape_type_to_xml = { 'ellipse': ellipse_to_xml, 'line': line_to_xml, 'path': path_to_xml, 'polygon': polygon_to_xml, 'rectangle': rectangle_to_xml, } def layer_transforms_to_xml_string(meta): """Get the xml representation[1]_[2]_ of the layer transforms. Parameters ---------- meta : dict The metadata from the layer. Returns ------- tf_list : str The transformation list represented as a string. References ---------- .. [1] https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/transform .. [2] https://www.w3.org/TR/css-transforms-1/ """ scale = meta.get('scale', [1, 1])[::-1] translate = meta.get('translate', [0, 0])[::-1] rotmat = meta.get('rotate', [[1, 0], [0, 1]]) rotate = np.degrees(np.arctan2(rotmat[0][1], rotmat[1][1])) # 'shear' in napari specifies the skew along the y-axis in CSS/SVG, but # the latter is in degrees. # skew along x can be achieved by combining skewY with a rotation of the # same amount. # https://www.w3.org/TR/css-transforms-1/#funcdef-transform-skewy skewy = np.degrees(np.arctan2(meta.get('shear', [0])[0], 1)) # matrix elements after converting row-column to y, x, first # flipping the rows and then the first two columns of the matrix: # a c e -> b d f -> d b f # b d f -> a c e -> c a e d, b, f, c, a, e = np.asarray(meta.get('affine', np.eye(3)))[:-1].ravel() strs = [ f'scale({scale[0]} {scale[1]})', f'skewY({skewy})', f'rotate({rotate})', f'translate({translate[0]} {translate[1]})', f'matrix({a} {b} {c} {d} {e} {f})', ] # Note: transforms are interpreted right-to-left in svg, so must be # inverted here. return ' '.join(strs[::-1]) def make_linear_matrix_and_offset(meta): """Make a transformation matrix from the layer metadata.""" rotate = np.array(meta.get('rotate', [[1, 0], [0, 1]])) shear = np.array([[1, meta.get('shear', [0])[0]], [0, 1]]) scale = np.diag(meta.get('scale', [1, 1])) translate = np.array(meta.get('translate', [0, 0])) affine = np.array(meta.get('affine', np.eye(3))) linear = affine[:2, :2] affine_tr = affine[:2, 2] matrix = linear @ (rotate @ shear @ scale) offset = linear @ translate + affine_tr return matrix, offset def extrema_coords(coords, meta): """Compute the extrema of a set of coordinates after transforms in meta.""" matrix, offset = make_linear_matrix_and_offset(meta) transformed_data = coords @ matrix.T + offset return np.array([ np.min(transformed_data, axis=0), np.max(transformed_data, axis=0) ]) def extrema_image(image, meta): """Compute the extrema of an image layer, accounting for transforms.""" coords = np.array([[0, 0], list(image.shape[:2])]) return extrema_coords(coords, meta) def image_to_xml(data, meta): """Generates a xml data for an image. Only two dimensional data (including rgb, and multiscale) is supported. For multiscale data the lowest resolution is used. The xml data is a list with a single xml element that defines the currently viewed image as a png according to the svg specification. Parameters ---------- data : array or list of array Image data. Only two dimensional data (including rgb, and multiscale) is supported. For multiscale data the lowest resolution is used. meta : dict Image metadata. Returns ------- layer_xml : xml.etree.ElementTree.Element Single xml element specifying the image as a png according to the svg specification. extrema : array (2, 2) Extrema of data, specified as a minumum then maximum of the (x, y) coordinates. """ # Extract metadata parameters if 'multiscale' in meta: multiscale = meta['multiscale'] else: multiscale = False if 'rgb' in meta: rgb = meta['rgb'] else: rgb = False if 'contrast_limits' in meta: contrast_limits = meta['contrast_limits'] else: contrast_limits = [0, 1] colormap = meta.get('colormap', 'gray') if 'opacity' in meta: opacity = meta['opacity'] else: opacity = 1 # Check if data is multiscale, and if so take only last layer if multiscale: data = data[-1] data = np.squeeze(data) # Check if more than 2 dimensional and if so error. if data.ndim - int(rgb) > 2: raise ValueError(f'Image must be 2 dimensional, not {data.ndim - int(rgb)} to save as svg') else: image = data # Find extrema of data extrema = extrema_image(image, meta) if rgb: mapped_image = image else: # apply contrast_limits to data image = np.clip( image, contrast_limits[0], contrast_limits[1] ) image = image - contrast_limits[0] color_range = contrast_limits[1] - contrast_limits[0] if color_range != 0: image = image / color_range cmap = ensure_colormap(colormap) # to keep backward compatibility with napari <0.4.18 # because of a bug in `vmap.map`, we need to ravel, map, then reshape mapped_image = (cmap.map(image.ravel()).reshape(image.shape + (4,)) * 255).astype(np.uint8) image_str = imwrite('', mapped_image, format='png') image_str = "data:image/png;base64," + str(b64encode(image_str))[2:-1] props = {'xlink:href': image_str} width = str(image.shape[1]) height = str(image.shape[0]) transform = layer_transforms_to_xml_string(meta) layer_xml = Element( 'image', width=width, height=height, opacity=str(opacity), transform=transform, **props, ) return layer_xml, extrema def extrema_points(data, meta): """Compute the extrema of points, taking transformations into account.""" # TODO: account for point sizes below, not just positions # could do so by offsetting coordinates along both axes, see for example: # https://github.com/scikit-image/scikit-image/blob/fa2a326a734c14b05c25057b03d31c84a6c8a635/skimage/morphology/convex_hull.py#L138-L140 return extrema_coords(data, meta) def points_to_xml(data, meta): """Generates a xml data for points. Only two dimensional points data is supported. Z ordering of the points will be taken into account. Each point is represented by a circle. Support for other symbols is not yet implemented. Note: any shear or anisotropic scaling value will be applied to the points, so the markers themselves will be transformed and not perfect circles anymore. Parameters ---------- data : array Points data. Only two dimensional points data is supported. meta : dict Points metadata. Returns ------- layer_xml : xml.etree.ElementTree.Element XML group element containing each point according to the svg specification. extrema : array (2, 2) Extrema of data, specified as a minumum then maximum of the (x, y) coordinates. """ # Extract metadata parameters if 'size' in meta: size = meta['size'] if size.ndim == 2: # backward compatibility for napari 2: raise ValueError('Points must be 2 dimensional to save as svg') else: points = data # Find extrema of data extrema = extrema_points(points, meta) # Ensure stroke width is an array to handle older versions of # napari (e.g. v0.4.0) where it could be a scalar. stroke_width = np.broadcast_to(stroke_width, (data.shape[0],)).copy() if meta.get('border_width_is_relative') or meta.get('edge_width_is_relative'): stroke_width = (stroke_width * size) transform = layer_transforms_to_xml_string(meta) layer_xml = Element('g', transform=transform) for i, (p, s, fc, sc, sw) in enumerate(zip(points, size, face_color, stroke_color, stroke_width)): cx = str(p[1]) cy = str(p[0]) r = str(s / 2) fc_int = (255 * fc).astype(int) fill = f'rgb{tuple(map(int, fc_int[:3]))}' sc_int = (255 * sc).astype(int) stroke = f'rgb{tuple(map(int, sc_int[:3]))}' props = { 'stroke-width': str(sw), 'opacity': str(opacity), } element = Element( 'circle', cx=cx, cy=cy, r=r, stroke=stroke, fill=fill, **props, ) layer_xml.append(element) if text is not None and 'array' in text['string'] and text['visible']: text_color_int = (255 * np.array(text['color']['constant'])).astype(int) text_fill = f'rgba{tuple(map(int, text_color_int))}' text_element = Element( 'text', x=cx, y=cy, fill=text_fill, **{'font-size': str(text['size'])} ) text_element.text = text['string']['array'][i] layer_xml.append(text_element) return layer_xml, extrema def extrema_shapes(shapes_data, meta): """Compute the extrema of shapes, taking transformations into account.""" coords = np.concatenate(shapes_data, axis=0) return extrema_coords(coords, meta) def shapes_to_xml(data, meta): """Generates a xml data for shapes. Only two dimensional shapes data is supported. Z ordering of the shapes will be taken into account. Parameters ---------- data : list of array Shapes data. Only two dimensional shapes data is supported. meta : dict Shapes metadata. Returns ------- layer_xml : xml.etree.ElementTree.Element XML group element containing each shape according to the svg specification extrema : array (2, 2) Extrema of data, specified as a minumum then maximum of the (x, y) coordinates. """ # Extract metadata parameters if 'face_color' in meta: face_color = meta['face_color'] else: face_color = np.ones((len(data), 4)) if 'edge_color' in meta: edge_color = meta['edge_color'] else: edge_color = np.zeros((len(data), 4)) edge_color[:, 3] = 1 if 'z_index' in meta: z_index = meta['z_index'] else: z_index = np.zeros(len(data)) if 'edge_width' in meta: edge_width = meta['edge_width'] else: edge_width = np.ones(len(data)) if 'opacity' in meta: opacity = meta['opacity'] else: opacity = 1 if 'shape_type' in meta: shape_type = meta['shape_type'] else: shape_type = ['rectangle'] * len(data) shapes = data if len(shapes) > 0: # Find extrema of data extrema = extrema_shapes(shapes, meta) else: # use nan — these will be discarded when aggregating all layers extrema = np.full((2, 2), np.nan) transform = layer_transforms_to_xml_string(meta) layer_xml = Element('g', transform=transform) raw_xml_list = [] zipped = zip(shapes, shape_type, face_color, edge_color, edge_width) for s, st, fc, ec, ew in zipped: props = { 'stroke-width': str(ew), 'opacity': str(opacity), } fc_int = (255 * fc).astype(int) props['fill'] = f'rgb{tuple(map(int, fc_int[:3]))}' ec_int = (255 * ec).astype(int) props['stroke'] = f'rgb{tuple(map(int, ec_int[:3]))}' shape_to_xml_func = shape_type_to_xml[st] element = shape_to_xml_func(s, props) raw_xml_list.append(element) # reorder according to z-index for i in np.argsort(z_index): layer_xml.append(raw_xml_list[i]) return layer_xml, extrema def extrema_vectors(vectors, meta): """Compute the extrema of vectors, taking projections into account.""" length = meta.get('length', 1) start_ends = np.empty( (vectors.shape[0] * vectors.shape[1], vectors.shape[-1]), dtype=vectors.dtype, ) start_ends[:vectors.shape[0]] = vectors[:, 0, :] start_ends[vectors.shape[0]:] = ( vectors[:, 0, :] + length * vectors[:, 1, :] ) return extrema_coords(start_ends, meta) def vectors_to_xml(data, meta): """Generates a xml data for vectors. Only two dimensional vectors data is supported. Each vector is represented by a line. Parameters ---------- data : array Vectors data. Only two dimensional vectors data is supported. meta : dict Points metadata. Returns ------- layer_xml : xml.etree.ElementTree.Element XML group element containing each vector as a line according to the svg specification extrema : array (2, 2) Extrema of data, specified as a minumum then maximum of the (x, y) coordinates. """ # Extract metadata parameters if 'edge_color' in meta: edge_color = meta['edge_color'] else: edge_color = np.zeros((data.shape[0], 4)) edge_color[:, 3] = 1 if 'edge_width' in meta: edge_width = meta['edge_width'] else: edge_width = 1 if 'length' in meta: length = meta['length'] else: length = 1 if 'opacity' in meta: opacity = meta['opacity'] else: opacity = 1 # Check if more than 2 dimensional and if so error. if data.shape[2] > 2: raise ValueError('Vectors must be 2 dimensional to save as svg') else: vectors = data # Find extrema of data extrema = extrema_vectors(vectors, meta) transform = layer_transforms_to_xml_string(meta) layer_xml = Element('g', transform=transform) props = { 'stroke-width': str(edge_width), 'opacity': str(opacity), } for v, ec in zip(vectors, edge_color): x1 = str(v[0, -2]) y1 = str(v[0, -1]) x2 = str(v[0, -2] + length * v[1, -2]) y2 = str(v[0, -1] + length * v[1, -1]) ec_int = (255 * ec).astype(int) stroke = f'rgb{tuple(map(int, ec_int[:3]))}' props['stroke'] = stroke element = Element('line', x1=y1, y1=x1, x2=y2, y2=x2, **props) layer_xml.append(element) return layer_xml, extrema napari-svg-0.2.1/napari_svg/napari.yaml000066400000000000000000000005251474141007300200530ustar00rootroot00000000000000name: napari-svg display_name: napari SVG contributions: commands: - id: napari-svg.svg_writer title: Write SVG python_name: napari_svg.hook_implementations:writer writers: - command: napari-svg.svg_writer layer_types: ["image*", "labels*", "points*", "shapes*", "vectors*"] filename_extensions: [".svg"] napari-svg-0.2.1/napari_svg/xml_to_svg.py000066400000000000000000000030141474141007300204440ustar00rootroot00000000000000from xml.etree.ElementTree import Element, tostring import numpy as np def xml_to_svg(xml_list, extrema): """Convert a list of xml into an SVG string. Parameters ---------- xml_list : list of xml.etree.ElementTree.Element List of a xml elements in the svg specification. extrema : array (2, 2) Extrema of data, specified as a minumum then maximum of the (x, y) coordinates. Used to specify the view box of SVG canvas. Returns ------- svg : string SVG representation of the layer. """ extrema = np.nan_to_num(extrema) corner = extrema[0] shape = extrema[1] - extrema[0] # set any 0 values in the shape to 1 to prevent height or width = 0 # see: https://github.com/napari/napari-svg/pull/12 shape[shape == 0] = 1 props = { 'xmlns': 'http://www.w3.org/2000/svg', 'xmlns:xlink': 'http://www.w3.org/1999/xlink', } xml = Element( 'svg', height=f'{shape[0]}', width=f'{shape[1]}', version='1.1', **props, ) transform = f'translate({-corner[1]} {-corner[0]})' xml_transform = Element('g', transform=transform) for x in xml_list: xml_transform.append(x) xml.append(xml_transform) svg = ( '\n' + '\n' + tostring(xml, encoding='unicode', method='xml') ) return svg napari-svg-0.2.1/pyproject.toml000066400000000000000000000032531474141007300165010ustar00rootroot00000000000000[project] name = "napari-svg" description = "A plugin for writing svg files with napari" license = {text = "BSD-3"} authors = [ {name = "Nicholas Sofroniew", email = "sofroniewn@gmail.com"}, {name = "napari core devs", email = "napari-core-devs@googlegroups.com"}, ] classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", "Framework :: napari", "Topic :: Software Development :: Testing", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Operating System :: OS Independent", "License :: OSI Approved :: BSD License", ] readme = {file = "README.md", content-type = "text/markdown"} requires-python = ">=3.9" dependencies = [ "imageio>=2.5.0", "numpy>=1.16.0", "vispy>=0.6.4", ] dynamic = ["version"] [project.optional-dependencies] testing = [ "napari>=0.4", "pyqt5", "pytest", "pytest-cov", ] [project.entry-points."napari.manifest"] napari-svg = "napari_svg:napari.yaml" [project.urls] Source = 'https://github.com/napari/napari-svg' "Bug Tracker" = 'https://github.com/napari/napari-svg/issues' [build-system] requires = [ "setuptools >= 56", "wheel", "setuptools_scm[toml]>=8.0" ] build-backend = "setuptools.build_meta" [tool.setuptools_scm] write_to = "napari_svg/_version.py" [tool.setuptools] include-package-data = true license-files = ['LICENSE'] zip-safe = false [tool.setuptools.packages.find] include = ['napari_svg'] exclude = ['docs'] namespaces = false [tool.setuptools.package-data] napari_svg = ['*napari.yaml'] napari-svg-0.2.1/tox.ini000066400000000000000000000011201474141007300150670ustar00rootroot00000000000000# For more information about tox, see https://tox.readthedocs.io/en/latest/ [tox] envlist = py{39,py312}-{linux,macos,windows} [gh-actions] python = 3.9: py39 3.12: py312 [gh-actions:env] PLATFORM = ubuntu-latest: linux macos-latest: macos windows-latest: windows [testenv] platform = macos: darwin linux: linux windows: win32 passenv = CI GITHUB_ACTIONS DISPLAY XAUTHORITY PYVISTA_OFF_SCREEN deps = pytest-xvfb ; sys_platform == 'linux' extras = testing commands = pytest -v --color=yes --cov=napari_svg --cov-report=xml