├── .github └── workflows │ └── test_and_deploy.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CNAME ├── LICENSE ├── MANIFEST.in ├── README.md ├── docs ├── contributing.md ├── getting_started.md ├── index.md ├── installation.md ├── resources │ └── getting_started │ │ ├── move_render_plane.png │ │ ├── open_surforama.png │ │ ├── save_points.png │ │ └── start_picking.png ├── surforama_data_portal_002.png ├── surforama_data_portal_002_light.png ├── surforama_for_data_portal.md └── surforama_screenshot.png ├── examples ├── example_usecase_2D_averages.ipynb └── example_usecase_stats.ipynb ├── mkdocs.yml ├── pyproject.toml ├── setup.cfg ├── src └── surforama │ ├── __init__.py │ ├── _cli.py │ ├── _tests │ ├── __init__.py │ ├── test_geometry.py │ └── test_widget.py │ ├── app.py │ ├── constants.py │ ├── data │ ├── __init__.py │ └── _datasets.py │ ├── gui │ ├── __init__.py │ ├── qt_mesh_generator.py │ ├── qt_point_io.py │ └── qt_surface_picker.py │ ├── io │ ├── __init__.py │ ├── _reader_plugin.py │ ├── _tests │ │ ├── __init__.py │ │ └── test_star.py │ ├── mesh.py │ └── star.py │ ├── napari.yaml │ └── utils │ ├── __init__.py │ ├── geometry.py │ ├── napari.py │ ├── stats.py │ └── twoD_averages.py ├── stylesheets └── extra.css └── tox.ini /.github/workflows/test_and_deploy.yml: -------------------------------------------------------------------------------- 1 | # This workflows will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: tests 5 | 6 | on: 7 | push: 8 | branches: 9 | - main 10 | - npe2 11 | tags: 12 | - "v*" # Push events to matching v*, i.e. v1.0, v20.15.10 13 | pull_request: 14 | branches: 15 | - main 16 | - npe2 17 | workflow_dispatch: 18 | 19 | jobs: 20 | test: 21 | name: ${{ matrix.platform }} py${{ matrix.python-version }} 22 | runs-on: ${{ matrix.platform }} 23 | strategy: 24 | matrix: 25 | platform: [ubuntu-latest, windows-latest, macos-13] 26 | python-version: [3.8, 3.9, '3.10'] 27 | 28 | steps: 29 | - uses: actions/checkout@v2 30 | 31 | - name: Set up Python ${{ matrix.python-version }} 32 | uses: actions/setup-python@v2 33 | with: 34 | python-version: ${{ matrix.python-version }} 35 | 36 | # these libraries enable testing on Qt on linux 37 | - uses: tlambert03/setup-qt-libs@v1 38 | 39 | # strategy borrowed from vispy for installing opengl libs on windows 40 | - name: Install Windows OpenGL 41 | if: runner.os == 'Windows' 42 | run: | 43 | git clone --depth 1 https://github.com/pyvista/gl-ci-helpers.git 44 | powershell gl-ci-helpers/appveyor/install_opengl.ps1 45 | 46 | # note: if you need dependencies from conda, considering using 47 | # setup-miniconda: https://github.com/conda-incubator/setup-miniconda 48 | # and 49 | # tox-conda: https://github.com/tox-dev/tox-conda 50 | - name: Install dependencies 51 | run: | 52 | python -m pip install --upgrade pip 53 | python -m pip install setuptools tox tox-gh-actions 54 | 55 | # this runs the platform-specific tests declared in tox.ini 56 | - name: Test with tox 57 | uses: GabrielBB/xvfb-action@v1 58 | with: 59 | run: python -m tox 60 | env: 61 | PLATFORM: ${{ matrix.platform }} 62 | 63 | - name: Coverage 64 | if: runner.os != 'macOS' 65 | uses: codecov/codecov-action@v3 66 | 67 | docs: 68 | needs: [test] # runs only after the other CI tests pass 69 | permissions: 70 | contents: write 71 | runs-on: ubuntu-latest 72 | steps: 73 | - uses: actions/checkout@v4 74 | - uses: tlambert03/setup-qt-libs@v1 75 | # Install dependencies 76 | - name: Set up Python 3.9 77 | uses: actions/setup-python@v5 78 | with: 79 | python-version: 3.9 80 | - name: Install dependencies 81 | run: | 82 | pip install "PyOpenGL<3.1.7" 83 | pip install ".[docs]" 84 | pip list 85 | # Build the book 86 | - name: Build the book 87 | uses: aganders3/headless-gui@v2 88 | with: 89 | run: mkdocs build --strict 90 | # Upload artifact 91 | - name: Upload artifact 92 | uses: actions/upload-artifact@v4 93 | with: 94 | name: site 95 | path: ./site 96 | # deploy when pushing to main 97 | - name: GitHub Pages action 98 | if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} 99 | uses: peaceiris/actions-gh-pages@v4 100 | with: 101 | github_token: ${{ secrets.GITHUB_TOKEN }} 102 | publish_dir: ./site 103 | 104 | deploy: 105 | # this will run when you have tagged a commit, starting with "v*" 106 | # and requires that you have put your twine API key in your 107 | # github secrets (see readme for details) 108 | needs: [test] 109 | runs-on: ubuntu-22.04 110 | if: contains(github.ref, 'tags') 111 | steps: 112 | - uses: actions/checkout@v2 113 | - name: Set up Python 114 | uses: actions/setup-python@v2 115 | with: 116 | python-version: "3.x" 117 | - name: Install dependencies 118 | run: | 119 | python -m pip install --upgrade pip 120 | pip install -U setuptools setuptools_scm wheel twine build 121 | - name: Build and publish 122 | env: 123 | TWINE_USERNAME: __token__ 124 | TWINE_PASSWORD: ${{ secrets.TWINE_API_KEY }} 125 | run: | 126 | git tag 127 | python -m build . 128 | twine upload dist/* 129 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | .idea 4 | 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | _version.py 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | wheels/ 28 | share/python-wheels/ 29 | *.egg-info/ 30 | .installed.cfg 31 | *.egg 32 | MANIFEST 33 | 34 | # PyInstaller 35 | # Usually these files are written by a python script from a template 36 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 37 | *.manifest 38 | *.spec 39 | 40 | # Installer logs 41 | pip-log.txt 42 | pip-delete-this-directory.txt 43 | 44 | # Unit test / coverage reports 45 | htmlcov/ 46 | .tox/ 47 | .nox/ 48 | .coverage 49 | .coverage.* 50 | .cache 51 | nosetests.xml 52 | coverage.xml 53 | *.cover 54 | *.py,cover 55 | .hypothesis/ 56 | .pytest_cache/ 57 | cover/ 58 | 59 | # Translations 60 | *.mo 61 | *.pot 62 | 63 | # Django stuff: 64 | *.log 65 | local_settings.py 66 | db.sqlite3 67 | db.sqlite3-journal 68 | 69 | # Flask stuff: 70 | instance/ 71 | .webassets-cache 72 | 73 | # Scrapy stuff: 74 | .scrapy 75 | 76 | # Sphinx documentation 77 | docs/_build/ 78 | 79 | # PyBuilder 80 | .pybuilder/ 81 | target/ 82 | 83 | # Jupyter Notebook 84 | .ipynb_checkpoints 85 | 86 | # IPython 87 | profile_default/ 88 | ipython_config.py 89 | 90 | # pyenv 91 | # For a library or package, you might want to ignore these files since the code is 92 | # intended to run in multiple environments; otherwise, check them in: 93 | # .python-version 94 | 95 | # pipenv 96 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 97 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 98 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 99 | # install all needed dependencies. 100 | #Pipfile.lock 101 | 102 | # poetry 103 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 104 | # This is especially recommended for binary packages to ensure reproducibility, and is more 105 | # commonly ignored for libraries. 106 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 107 | #poetry.lock 108 | 109 | # pdm 110 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 111 | #pdm.lock 112 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 113 | # in version control. 114 | # https://pdm.fming.dev/#use-with-ide 115 | .pdm.toml 116 | 117 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 118 | __pypackages__/ 119 | 120 | # Celery stuff 121 | celerybeat-schedule 122 | celerybeat.pid 123 | 124 | # SageMath parsed files 125 | *.sage.py 126 | 127 | # Environments 128 | .env 129 | .venv 130 | env/ 131 | venv/ 132 | ENV/ 133 | env.bak/ 134 | venv.bak/ 135 | 136 | # Spyder project settings 137 | .spyderproject 138 | .spyproject 139 | 140 | # Rope project settings 141 | .ropeproject 142 | 143 | # mkdocs documentation 144 | /site 145 | 146 | # mypy 147 | .mypy_cache/ 148 | .dmypy.json 149 | dmypy.json 150 | 151 | # Pyre type checker 152 | .pyre/ 153 | 154 | # pytype static type analyzer 155 | .pytype/ 156 | 157 | # Cython debug symbols 158 | cython_debug/ 159 | 160 | # PyCharm 161 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 162 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 163 | # and can be added to the global gitignore or merged into this file. For a more nuclear 164 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 165 | #.idea/ 166 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.5.0 4 | hooks: 5 | - id: check-docstring-first 6 | - id: end-of-file-fixer 7 | - id: trailing-whitespace 8 | exclude: ^\.napari-hub/.* 9 | - id: check-yaml # checks for correct yaml syntax for github actions ex. 10 | exclude: mkdocs.yml 11 | - repo: https://github.com/charliermarsh/ruff-pre-commit 12 | rev: v0.2.2 13 | hooks: 14 | - id: ruff 15 | - repo: https://github.com/psf/black 16 | rev: 24.2.0 17 | hooks: 18 | - id: black 19 | - repo: https://github.com/tlambert03/napari-plugin-checks 20 | rev: v0.3.0 21 | hooks: 22 | - id: napari-plugin-checks 23 | -------------------------------------------------------------------------------- /CNAME: -------------------------------------------------------------------------------- 1 | surforama.cellcanvas.org -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Kyle I S Harrington 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | 4 | recursive-exclude * __pycache__ 5 | recursive-exclude * *.py[co] 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # surforama 2 | a napari-based tool for using surfaces to explore volumetric data in napari 3 | 4 | inspired by [membranorama](https://github.com/dtegunov/membranorama) 5 | 6 | ![Screenshot of surforama showing a surface in the slice of a tomogram](./docs/surforama_screenshot.png) 7 | 8 | ## installation 9 | `surforama` requires the napari viewer. If you would like to install napari and surforama together in one line, you can use the following command: 10 | 11 | ```bash 12 | pip install "surforama[napari]" 13 | ``` 14 | 15 | 16 | If you already have napari installed, you can directly install surforama in the same environment: 17 | 18 | ```bash 19 | pip install surforama 20 | ``` 21 | 22 | ## usage 23 | ### launch with demo data 24 | If you'd like to test surforama out, you can launch surforama with demo data: 25 | 26 | ```bash 27 | surforama --demo 28 | ``` 29 | 30 | ### launch without data 31 | You can launch surforama using the command line interface. After you have installed surforama, you can launch it with the following command in your terminal: 32 | 33 | ```bash 34 | surforama 35 | ``` 36 | After surforama launches, you can load your image and mesh into napari and get surfing! 37 | 38 | ### launch with data 39 | If you have an MRC-formatted tomogram and an obj-formatted mesh, you can launch using the following command: 40 | 41 | ```bash 42 | surforama --image-path /path/to/image.mrc --mesh-path /path/to/mesh.obj 43 | ``` 44 | 45 | ## developer installation 46 | 47 | If you would like to make changes to the surforama source code, you can install surformama with the developer tools as follows: 48 | 49 | ```bash 50 | cd /path/to/your/surforama/source/code/folder 51 | pip install -e ".[dev]" 52 | ``` 53 | We use pre-commit to keep the code tidy. Install the pre-commit hooks to activate the checks: 54 | 55 | ```bash 56 | pre-commit install 57 | ``` 58 | -------------------------------------------------------------------------------- /docs/contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing to Surforama 2 | ## developer installation 3 | 4 | If you would like to make changes to the surforama source code, you can install surformama with the developer tools as follows: 5 | 6 | ```bash 7 | cd /path/to/your/surforama/source/code/folder 8 | pip install -e ".[dev]" 9 | ``` 10 | We use pre-commit to keep the code tidy. Install the pre-commit hooks to activate the checks: 11 | 12 | ```bash 13 | pre-commit install 14 | ``` 15 | -------------------------------------------------------------------------------- /docs/getting_started.md: -------------------------------------------------------------------------------- 1 | # Getting started with surforama 2 | 3 | This short tutorial explains how to annotate particles on your first membrane with `Surforama`. If you haven't already installed `Surforama`, please see our [installation instructions](installation.md). First, open your terminal and activate the Python environment with `Surforma` installed. Then launch `Surforama` with the included sample data with the following command 4 | 5 | ```bash 6 | surforama --demo 7 | ``` 8 | 9 | This will launch the napari viewer with the Surforama plugins and sample data open. Note that the first time you launch napari may take a bit of extra time. The sample data is a tomogram and of a Chal chloroplast and a mesh of a segment of membrane. In this demo, we will annotate the photosystem II densities on the surface of this membrane. The tomogram is loaded as an image layer and the mesh of the membrane surface is loaded as a surface layer. 10 | 11 | ![Surforama opened](./resources/getting_started/open_surforama.png){ align=center } 12 | 13 | The tomogram is rendered as a 2D slice. You can move the position of the slice being rendered by first selecting the image layer (named "tomogram") from the layer list and then holding the "shift" key while clicking and dragging on the plane with your left mouse button. You can rotate the view by clicking and dragging in the canvas and zoom by using the scroll wheel on your mouse. 14 | 15 | ![Tomogram selection](./resources/getting_started/move_render_plane.png){ align=center } 16 | 17 | ![type:video](https://github.com/user-attachments/assets/d42aa634-6e21-4f62-888a-390617393690) 18 | 19 | We can now initialize the picking mode that will allow you to annotate particle locations and orientations on the membrane. First, select the mesh layer from the layer list (named "mesh_data"). Then click the "start picking" button. 20 | 21 | ![Start annotating](./resources/getting_started/start_picking.png) 22 | 23 | With the picking mode activated, we can now annotate particles on the membrane surface. We can select the centroid of a particle by clicking on it. This will place the particle. We can then set the orientation by adjusting the orientation slider underneath the "enable/disable" picking button (denoted in the screenshot above). The orientation of the particle is indicated by orange arrow point away from the centroid marker. 24 | 25 | ![type:video](https://github.com/user-attachments/assets/8e5ad177-d9af-4c7b-9524-42f0f3f8389d) 26 | 27 | Finally, you can output your annotated particles as a Relion-formatted Star file. To do so, enter the file path in the "Save" table of the "Save points" widget and click the "Save to star file" button. 28 | 29 | ![Save points](./resources/getting_started/save_points.png){ align=center } 30 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Surforama 2 | a napari-based tool for using surfaces to explore volumetric data. Using `Surforama` you can visualize densities on a membrane surface, annotate particle locations and orientations, and analyze the picked particles. 3 | 4 | inspired by [membranorama](https://github.com/dtegunov/membranorama) 5 | 6 | ![Screenshot of surforama showing a surface in the slice of a tomogram](./surforama_screenshot.png) 7 | 8 | ## installation 9 | To install `Surforama`, see our installation guides for [users](installation.md) and [developers](contributing.md). 10 | 11 | ## getting started 12 | If you would like to give `Surforama` a try, check out our [Getting Started](getting_started.md) tutorial. 13 | -------------------------------------------------------------------------------- /docs/installation.md: -------------------------------------------------------------------------------- 1 | # Installing surforama 2 | 3 | `Surforama` is a python package that can be installed via PyPI. If you are just getting started, we recommend installing Surforama via an environment manager such as `venv` or `mamba`. We recommend Python 3.9 or greater. Once you have set up your Python environment, you can install `Surforama` with the following commands. 4 | 5 | `surforama` requires the napari viewer. If you would like to install napari and surforama together in one line, you can use the following command: 6 | 7 | ```bash 8 | pip install "surforama[napari]" 9 | ``` 10 | 11 | If you already have napari installed, you can directly install surforama in the same environment: 12 | 13 | ```bash 14 | pip install surforama 15 | ``` 16 | 17 | After installation completes, you can test the installation using our [Getting Started](getting_started.md) tutorial. If you have questions you can post on the CellCanvas stream of the [image.sc zulip chat](http://imagesc.zulipchat.com/). 18 | 19 | ## Developer installation 20 | If you would like to contribute to `Surforama`, please see our [Contributing Guide](contributing.md). 21 | -------------------------------------------------------------------------------- /docs/resources/getting_started/move_render_plane.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cellcanvas/surforama/15401ce542c3144dec34a7eac016703e970ab064/docs/resources/getting_started/move_render_plane.png -------------------------------------------------------------------------------- /docs/resources/getting_started/open_surforama.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cellcanvas/surforama/15401ce542c3144dec34a7eac016703e970ab064/docs/resources/getting_started/open_surforama.png -------------------------------------------------------------------------------- /docs/resources/getting_started/save_points.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cellcanvas/surforama/15401ce542c3144dec34a7eac016703e970ab064/docs/resources/getting_started/save_points.png -------------------------------------------------------------------------------- /docs/resources/getting_started/start_picking.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cellcanvas/surforama/15401ce542c3144dec34a7eac016703e970ab064/docs/resources/getting_started/start_picking.png -------------------------------------------------------------------------------- /docs/surforama_data_portal_002.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cellcanvas/surforama/15401ce542c3144dec34a7eac016703e970ab064/docs/surforama_data_portal_002.png -------------------------------------------------------------------------------- /docs/surforama_data_portal_002_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cellcanvas/surforama/15401ce542c3144dec34a7eac016703e970ab064/docs/surforama_data_portal_002_light.png -------------------------------------------------------------------------------- /docs/surforama_for_data_portal.md: -------------------------------------------------------------------------------- 1 | # Loading data from the [CZ cryoET Data Portal](https://cryoetdataportal.czscience.com/) 2 | 3 | For this code to work you'll need to install `cryoet-data-portal` which you can do by following the installation instructions on [this site](https://chanzuckerberg.github.io/cryoet-data-portal/). 4 | 5 | ![Screenshot of surforama with data from the CZ cryoet data portal. This shows the 3rd largest segmentatoin being rendered in surforama.](surforama_data_portal_002_light.png) 6 | 7 | ## Script 8 | 9 | This script will: 10 | 11 | - load a tomogram, membrane annotation, and points from the cryoET data portal 12 | - run connected components and return the 3rd largest component (in this dataset the large membranes can be slow for `surforama`) 13 | - open the Surforama widget 14 | 15 | 16 | Once that has happened there are some interactive steps: 17 | 18 | - select the layer for the 3rd largest component in `surforama` 19 | - run `Generate Mesh` 20 | - run `start surfing` 21 | - explore the surface of this section of membrane 22 | 23 | 24 | 25 | ```python 26 | import cryoet_data_portal as portal 27 | import zarr 28 | import napari 29 | import s3fs 30 | import ndjson 31 | import numpy as np 32 | import scipy.ndimage as ndi 33 | from skimage import measure 34 | import surforama 35 | 36 | # Instantiate a client, using the data portal GraphQL API by default 37 | client = portal.Client() 38 | 39 | fs = s3fs.S3FileSystem(anon=True) 40 | 41 | # Use the find method to select datasets that contain membrane annotations 42 | datasets = portal.Dataset.find(client, [portal.Dataset.runs.tomogram_voxel_spacings.annotations.object_name.ilike("%membrane%")]) 43 | dataset_id = datasets[0].id 44 | 45 | # An example Tomogram 46 | tomo = portal.Tomogram.find(client, [portal.Tomogram.tomogram_voxel_spacing.run.dataset_id == dataset_id])[0] 47 | run_id = tomo.tomogram_voxel_spacing.run.id 48 | 49 | # Show downscaled tomogram 50 | g = zarr.open_array(f"{tomo.https_omezarr_dir}/2", mode='r') 51 | 52 | # Annotations 53 | annotations = tomo.tomogram_voxel_spacing.annotations 54 | 55 | # Ribosomes 56 | ribosomes_a = annotations[0] 57 | ribosome_name = ribosomes_a.object_name 58 | ribosomes = [] 59 | with fs.open(ribosomes_a.files[0].s3_path) as pointfile: 60 | for point in ndjson.reader(pointfile): 61 | ribosomes.append((point['location']['z'], point['location']['y'], point['location']['x'])) 62 | 63 | # Membrane 64 | membrane_a = annotations[1] 65 | membrane_name = membrane_a.object_name 66 | membrane_path = membrane_a.files[0].https_path 67 | membrane = zarr.open_array(f"{membrane_path}/2") 68 | 69 | # Run connected components on the membrane data 70 | labeled_membrane, num_features = ndi.label(membrane) 71 | sizes = np.bincount(labeled_membrane.ravel()) 72 | 73 | # Exclude background (label 0) and sort the component sizes in descending order 74 | # Get the labels sorted in reverse by component size (excluding background) 75 | sorted_labels = np.argsort(sizes[1:])[::-1] + 1 76 | 77 | # Get the label of the 3rd largest component so we don't have a large mesh 78 | third_largest_label = sorted_labels[2] 79 | 80 | # Create a mask 81 | third_largest_component_mask = labeled_membrane == third_largest_label 82 | 83 | # Load into Surforama and Napari 84 | viewer = napari.Viewer(ndisplay=3) 85 | 86 | # Add tomogram, ribosomes, and membrane data to the viewer 87 | viewer.add_points(ribosomes, face_color="red") 88 | viewer.add_image(g, scale=(4, 4, 4)) 89 | viewer.add_labels(membrane, scale=(4, 4, 4)) 90 | viewer.add_labels(third_largest_component_mask, scale=(4, 4, 4)) 91 | 92 | # may need to use viewer.layers[-1].scale = (4, 4, 4) 93 | 94 | # Instantiate the Surforama widget 95 | surforama_widget = surforama.QtSurforama(viewer) 96 | viewer.window.add_dock_widget(surforama_widget, area="right", name="Surforama") 97 | 98 | napari.run() 99 | ``` -------------------------------------------------------------------------------- /docs/surforama_screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cellcanvas/surforama/15401ce542c3144dec34a7eac016703e970ab064/docs/surforama_screenshot.png -------------------------------------------------------------------------------- /examples/example_usecase_stats.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "## Surforama -- Example statistic computations \n", 8 | "\n", 9 | "This example notebook uses the .obj meshes and .star files generated from particle clicking in Surforama to compute some simple statistics:\n", 10 | "- Mesh occupancy: What is the particle density on the surface? (computed as #points / area)\n", 11 | "- Geodesic nearest neighbor distance: What is the distribution of nearest neighbor distances on the surface?\n", 12 | "- Orientation comparison between nearest neighbors: Is there any relation between in-plane orientations of nearest neighbors on the surface? (computed for 1- and 2-nearest neighbors)" 13 | ] 14 | }, 15 | { 16 | "cell_type": "code", 17 | "execution_count": 3, 18 | "metadata": {}, 19 | "outputs": [], 20 | "source": [ 21 | "import os\n", 22 | "import numpy as np\n", 23 | "\n", 24 | "from surforama.io.star import load_points_layer_data_from_star_file\n", 25 | "from surforama.io.mesh import read_obj_file\n", 26 | "\n", 27 | "from surforama.utils.stats import (\n", 28 | " compute_geod_distance_matrix,\n", 29 | " compute_surface_occupancy,\n", 30 | " orientations_of_knn_inplane\n", 31 | ")" 32 | ] 33 | }, 34 | { 35 | "cell_type": "code", 36 | "execution_count": 5, 37 | "metadata": {}, 38 | "outputs": [], 39 | "source": [ 40 | "pixel_size = 13.68 # pixel size in Angstrom\n", 41 | "\n", 42 | "example_usecase_dir = \"../../example_usecase/\"\n", 43 | "star_files = [\n", 44 | " \"M2.star\",\n", 45 | " \"M3b.star\",\n", 46 | " \"M3c.star\",\n", 47 | " \"M4b.star\",\n", 48 | " \"M5c.star\"\n", 49 | "]\n", 50 | "\n", 51 | "mesh_files = [\n", 52 | " \"Tomo1L1_M2.obj\",\n", 53 | " \"Tomo1L1_M3b.obj\",\n", 54 | " \"Tomo1L1_M3c.obj\",\n", 55 | " \"Tomo1L1_M4b.obj\",\n", 56 | " \"Tomo1L1_M5c.obj\",\n", 57 | "]\n", 58 | "\n", 59 | "star_files = [os.path.join(example_usecase_dir, f) for f in star_files]\n", 60 | "mesh_files = [os.path.join(example_usecase_dir, f) for f in mesh_files]" 61 | ] 62 | }, 63 | { 64 | "cell_type": "code", 65 | "execution_count": 6, 66 | "metadata": {}, 67 | "outputs": [ 68 | { 69 | "name": "stdout", 70 | "output_type": "stream", 71 | "text": [ 72 | "Computing stats for ../../example_usecase/M2.star and ../../example_usecase/Tomo1L1_M2.obj.\n", 73 | "Computing stats for ../../example_usecase/M3b.star and ../../example_usecase/Tomo1L1_M3b.obj.\n", 74 | "Computing stats for ../../example_usecase/M3c.star and ../../example_usecase/Tomo1L1_M3c.obj.\n", 75 | "Computing stats for ../../example_usecase/M4b.star and ../../example_usecase/Tomo1L1_M4b.obj.\n", 76 | "Computing stats for ../../example_usecase/M5c.star and ../../example_usecase/Tomo1L1_M5c.obj.\n" 77 | ] 78 | } 79 | ], 80 | "source": [ 81 | "method = \"fast\" # \"exact\" or \"fast\"\n", 82 | "nn_dists = []\n", 83 | "surface_occupancies = []\n", 84 | "knn_orientations = []\n", 85 | "for star_file, mesh_file in zip(star_files, mesh_files):\n", 86 | " print(f\"Computing stats for {star_file} and {mesh_file}.\")\n", 87 | " \n", 88 | " # Load data\n", 89 | " point_coordinates, feature_table = load_points_layer_data_from_star_file(star_file)\n", 90 | " verts, faces, _ = read_obj_file(mesh_file)\n", 91 | "\n", 92 | " # Set physical units\n", 93 | " point_coordinates *= pixel_size\n", 94 | " verts *= pixel_size\n", 95 | "\n", 96 | " # Compute nearest neighbor distances\n", 97 | " geod_distance_matrix = compute_geod_distance_matrix(verts, faces, point_coordinates, method=method)\n", 98 | " nn_dists.append(np.min(geod_distance_matrix, axis=1))\n", 99 | "\n", 100 | " # Compute orientations of k-nearest neighbors\n", 101 | " knn_orientations.append(orientations_of_knn_inplane(geod_distance_matrix, feature_table, k=2, c2_symmetry=True))\n", 102 | "\n", 103 | " # Compute surface occupancy\n", 104 | " surface_occ = compute_surface_occupancy(verts, faces, point_coordinates, only_front=True)\n", 105 | " surface_occupancies.append(surface_occ)" 106 | ] 107 | }, 108 | { 109 | "cell_type": "code", 110 | "execution_count": 18, 111 | "metadata": {}, 112 | "outputs": [], 113 | "source": [ 114 | "from matplotlib import pyplot as plt\n", 115 | "\n", 116 | "def plot_nn_distances(nn_dists):\n", 117 | " bins = np.linspace(5, 20, 15)\n", 118 | " bins *= pixel_size\n", 119 | " plt.figure(figsize=(20, 5))\n", 120 | " for i, nn_dist in enumerate(nn_dists):\n", 121 | " plt.subplot(1, len(nn_dists), i+1)\n", 122 | " plt.hist(nn_dist, bins=bins)\n", 123 | " plt.xlabel(\"Nearest neighbor distance (Angstrom)\")\n", 124 | " plt.ylabel(\"Count\")\n", 125 | " plt.suptitle(\"Nearest neighbor distances\")\n", 126 | " plt.tight_layout()\n", 127 | " plt.show()\n", 128 | "\n", 129 | "def plot_surface_occupancies(surface_occupancies):\n", 130 | " plt.figure()\n", 131 | " plt.title(\"Surface Occupancies\")\n", 132 | " plt.bar(np.arange(len(surface_occupancies)), np.array(surface_occupancies)*100)\n", 133 | " plt.xlabel(\"Membrane ID\")\n", 134 | " plt.ylabel(\"# proteins / nm^2\")\n", 135 | " plt.tight_layout()\n", 136 | " plt.show()\n", 137 | "\n", 138 | "def plot_knn_orientations(knn_orientations):\n", 139 | " bins = np.linspace(0., 90., 15)\n", 140 | " \n", 141 | " ax = plt.figure(figsize=(20, 5))\n", 142 | " for i, orientations in enumerate(knn_orientations):\n", 143 | " plt.subplot(1, len(knn_orientations), i+1)\n", 144 | " plt.hist(orientations, bins=bins, label=[f\"{i+1}-NN\" for i in range(orientations.shape[1])])\n", 145 | " plt.xlabel(\"Orientation (degrees)\")\n", 146 | " plt.ylabel(\"Count\")\n", 147 | " plt.legend()\n", 148 | " plt.suptitle(\"Orientations of k-nearest neighbors\")\n", 149 | " plt.tight_layout()\n", 150 | " plt.show()" 151 | ] 152 | }, 153 | { 154 | "cell_type": "markdown", 155 | "metadata": {}, 156 | "source": [ 157 | "## Plot the computed statistics and compare for each membrane" 158 | ] 159 | }, 160 | { 161 | "cell_type": "code", 162 | "execution_count": 19, 163 | "metadata": {}, 164 | "outputs": [ 165 | { 166 | "data": { 167 | "image/png": "iVBORw0KGgoAAAANSUhEUgAAAnYAAAHWCAYAAAD6oMSKAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjguNCwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy8fJSN1AAAACXBIWXMAAA9hAAAPYQGoP6dpAABCGUlEQVR4nO3deXxNB/7/8fdNSCJk0QaxhOQrWtQepFGKSoUxJd2oLlJSOkiLDFotoVWNUmqdqnZsMzWMUqNaIUWZVmqn1lpKY0tCNUkFCbnn94efO71N6L2SuOnxej4e98E553PO+ZxzpuP9OKvFMAxDAAAA+MNzc3UDAAAAKB4EOwAAAJMg2AEAAJgEwQ4AAMAkCHYAAAAmQbADAAAwCYIdAACASRDsAAAATIJgBwAAYBIEOwClWlJSkpo0aSIvLy9ZLBZlZma6uiWUgODgYD3//POubgP4wyPYAXDKnj179MQTT6hWrVry8vJS9erV9fDDD2v69OnFvq6ffvpJ3bt3V7ly5TRz5kz94x//UPny5Yt9PUV15coVTZs2TS1atJCPj48qVKigFi1aaNq0abpy5Yqr2wNwB7HwrVgAjtq0aZPat2+vmjVrKiYmRoGBgTpx4oS+/fZbHT16VEeOHCnW9SUlJalz585KTk5WZGRksS67uOTk5KhLly7asGGD/vznP6tTp05yc3NTUlKSVqxYobZt2+rzzz8vlYG0NMnNzZWbm5vKli3r6laAP7Qyrm4AwB/HuHHj5Ofnp61bt8rf399uWkZGRrGtJycnR+XLl7ct87frKk3i4+O1YcMGTZ8+XXFxcbbx/fv318yZMxUXF6ehQ4fq/fffd2GXpZ+np6erWwBMgUuxABx29OhR3XfffYUGrcqVK9v+fvz4cVksFs2bN69AncVi0ZgxY2zDY8aMkcVi0f79+/X000+rYsWKat26tdq1a6eYmBhJUosWLWSxWGz3YP33v//Vk08+qZo1a8rT01NBQUEaMmSILl26VGB9Bw8eVPfu3VWpUiWVK1dO9957r15//XW7mlOnTqlPnz6qUqWKPD09dd9992nOnDm/uz9Onjypv//973rooYfsQt11AwcOVPv27fXRRx/p5MmTdtP++c9/qmXLlvL29lbFihX14IMPas2aNXY1q1atUtu2beXj4yNfX1+1aNFCCxcutE2/0X1p7dq1U7t27WzDX331lSwWixYvXqzXXntNgYGBKl++vLp27aoTJ07Yzevovn3++edVoUIFnTp1StHR0apQoYIqVaqkoUOHKj8/367WarVq6tSpatiwoby8vFSpUiV16tRJ27Ztu+m2ZGZmavDgwQoKCpKnp6dCQ0P1zjvvyGq12tUtWrRIYWFhtv3UsGFDTZ06tcB+Ae4EnLED4LBatWopJSVFe/fuVYMGDYp12U8++aTq1Kmjt99+W4ZhqE6dOrr33ns1e/ZsvfnmmwoJCVHt2rUlSUuWLNHFixfVv39/3X333dqyZYumT5+ukydPasmSJbZlfvfdd2rTpo3Kli2rfv36KTg4WEePHtVnn32mcePGSZLS09N1//33y2KxKC4uTpUqVdKqVasUGxur7OxsDR48+IY9r1q1Svn5+erVq9cNa3r16qX169crKSlJL7zwgiTpjTfe0JgxY9SqVSu9+eab8vDw0ObNm7Vu3Tp17NhRkjRv3jz16dNH9913n0aMGCF/f3/t3LlTSUlJevrpp29pH48bN04Wi0WvvPKKMjIyNGXKFEVGRmrXrl0qV66cU/tWkvLz8xUVFaXw8HC9++67+vLLLzVp0iTVrl1b/fv3t9XFxsZq3rx56ty5s1544QVdvXpV//3vf/Xtt9+qefPmhfZ68eJFtW3bVqdOndKLL76omjVratOmTRoxYoTOnDmjKVOmSJKSk5PVs2dPdejQQe+8844k6cCBA/rmm280aNCgW9pPwB+aAQAOWrNmjeHu7m64u7sbERERxvDhw43Vq1cbeXl5dnXHjh0zJBlz584tsAxJxujRo23Do0ePNiQZPXv2LFA7d+5cQ5KxdetWu/EXL14sUJuYmGhYLBbjxx9/tI178MEHDR8fH7txhmEYVqvV9vfY2FijatWqxrlz5+xqnnrqKcPPz6/QdV03ePBgQ5Kxc+fOG9bs2LHDkGTEx8cbhmEYhw8fNtzc3IxHH33UyM/PL7SvzMxMw8fHxwgPDzcuXbp0w95r1aplxMTEFFhn27ZtjbZt29qG169fb0gyqlevbmRnZ9vG//vf/zYkGVOnTrWNc3TfxsTEGJKMN9980662adOmRlhYmG143bp1hiTj5ZdfLrDcm23L2LFjjfLlyxuHDh2ym+fVV1813N3djdTUVMMwDGPQoEGGr6+vcfXq1QLLB+5EXIoF4LCHH35YKSkp6tq1q3bv3q0JEyYoKipK1atX14oVK4q07L/85S8O114/uyRdux/v3LlzatWqlQzD0M6dOyVJZ8+e1caNG9WnTx/VrFnTbn6LxSJJMgxDS5cu1SOPPCLDMHTu3DnbLyoqSllZWdqxY8cN+/jll18kST4+PjesuT4tOztbkrR8+XJZrVYlJCTIzc3+/4Kv95WcnKxffvlFr776qry8vAqtuRW9evWy6/WJJ55Q1apV9cUXX9jGObJvf+23x61Nmzb64YcfbMNLly6VxWLR6NGjC8x7s21ZsmSJ2rRpo4oVK9odl8jISOXn52vjxo2Srt1/mZOTo+TkZAf2AGB+XIoF4JQWLVpo2bJlysvL0+7du/Xpp5/qvffe0xNPPKFdu3apfv36t7TckJAQh2tTU1OVkJCgFStW6Oeff7ablpWVJUm2cHGzS8Znz55VZmamZs+erdmzZxdac7OHQq6HpOsBrzC/DX9Hjx6Vm5vbTffT0aNHf7f3W1GnTh27YYvFotDQUB0/ftw2zpF9e931++V+rWLFinbzHT16VNWqVdNdd93lVK+HDx/Wd999V2D5110/LgMGDNC///1vde7cWdWrV1fHjh3VvXt3derUyan1AWZBsANwSzw8PNSiRQu1aNFC99xzj3r37q0lS5Zo9OjRNzwT89ub6n/t12eKbiY/P18PP/ywzp8/r1deeUV169ZV+fLlderUKT3//PMFbqy/meu1zz77rO1Bjd9q1KjRDeevV6+epGv38jVp0qTQmu+++06Sbjnw3szN9rO7u7vTy3N2397KOhxltVr18MMPa/jw4YVOv+eeeyRde2hn165dWr16tVatWqVVq1Zp7ty56tWrl+bPn19i/QGlFcEOQJFdvwH+zJkzkq6dtZFU4CsRP/74Y5HXtWfPHh06dEjz58+3e2jht5fi/u///k+StHfv3hsuq1KlSvLx8VF+fv4tvSevc+fOcnd31z/+8Y8bPkCxYMEClSlTxnYGqXbt2rJardq/f/8Nw+D1h0T27t2r0NDQG66/YsWKhX6J48cff7Rt/68dPnzYbtgwDB05csQWXh3dt86oXbu2Vq9erfPnzzt11q527dq6cOGCQ8fFw8NDjzzyiB555BFZrVYNGDBAH3zwgUaNGnXT/QeYEffYAXDY+vXrZRTyTvPr92jde++9kiRfX18FBATY7oO67m9/+1uRe7h+lujXfRiGUeD1FpUqVdKDDz6oOXPmKDU11W7a9Xnd3d31+OOPa+nSpYUGwLNnz960l6CgIPXu3Vtffvlloe+pmzVrltatW6fY2FjVqFFDkhQdHS03Nze9+eabBc6AXe+rY8eO8vHxUWJioi5fvlxojXQt/Hz77bfKy8uzjVu5cmWBV5hct2DBArvLxp988onOnDmjzp072/bHb9dR2L51xuOPPy7DMPTGG28UmFbY/5au6969u1JSUrR69eoC0zIzM3X16lVJ175O8mtubm62oJqbm3vLfQN/VJyxA+Cwl156SRcvXtSjjz6qunXrKi8vT5s2bdLixYsVHBys3r1722pfeOEFjR8/Xi+88IKaN2+ujRs36tChQ0XuoW7duqpdu7aGDh2qU6dOydfXV0uXLi1wP5gkTZs2Ta1bt1azZs3Ur18/hYSE6Pjx4/r888+1a9cuSdL48eO1fv16hYeHq2/fvqpfv77Onz+vHTt26Msvv9T58+dv2s97772ngwcPasCAAUpKSrKdmVu9erX+85//qG3btpo0aZKtPjQ0VK+//rrGjh2rNm3a6LHHHpOnp6e2bt2qatWqKTExUb6+vnrvvff0wgsvqEWLFrb3++3evVsXL160XWJ84YUX9Mknn6hTp07q3r27jh49qn/+85+2M36/ddddd6l169bq3bu30tPTNWXKFIWGhqpv375O71tHtW/fXs8995ymTZumw4cPq1OnTrJarfrvf/+r9u3bF/r+P0kaNmyYVqxYoT//+c96/vnnFRYWppycHO3Zs0effPKJjh8/roCAAL3wwgs6f/68HnroIdWoUUM//vijpk+friZNmtgulQN3FBc8iQvgD2rVqlVGnz59jLp16xoVKlQwPDw8jNDQUOOll14y0tPT7WovXrxoxMbGGn5+foaPj4/RvXt3IyMj44avOzl79myB9d3odSf79+83IiMjjQoVKhgBAQFG3759jd27dxf6ipW9e/cajz76qOHv7294eXkZ9957rzFq1Ci7mvT0dGPgwIFGUFCQUbZsWSMwMNDo0KGDMXv2bIf2S25urvHee+8ZYWFhRvny5Q1vb2+jWbNmxpQpUwq8Cua6OXPmGE2bNjU8PT2NihUrGm3btjWSk5PtalasWGG0atXKKFeunOHr62u0bNnS+Ne//mVXM2nSJKN69eqGp6en8cADDxjbtm27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3ZEuXLigI0eO2IaPHTumXbt26a677lLNmjVd2NmdaeDAgVq4cKH+85//yMfHR2lpaZIkPz8/lStXzsXd3ZlGjBihzp07q2bNmvrll1+0cOFCffXVV1q9erWrW7tj+fj4FLjvtHz58rr77ru5H/UmCHYoET169NDZs2eVkJCgtLQ0NWnSRElJSQUeqMDtsW3bNrVv3942HB8fL0mKiYnRvHnzXNTVnev999+XJLVr185u/Ny5c/X888/f/oagjIwM9erVS2fOnJGfn58aNWqk1atX6+GHH3Z1a4BTeI8dAACASXCPHQAAgEkQ7AAAAEyCYAcAAGASBDsAAACTINgBAACYBMEOAADAJAh2AAAAJkGwAwAAMAmCHQAUs3bt2mnw4MGubgPAHYhgB8A0nn/+eVksFv3lL38pMG3gwIGyWCx8susGfhtG27VrJ4vFIovFIk9PT1WvXl2PPPKIli1b5romAfwugh0AUwkKCtKiRYt06dIl27jLly9r4cKFqlmzpgs7u7m8vDxXt1BA3759debMGR09elRLly5V/fr19dRTT6lfv36ubg3ADRDsAJhKs2bNFBQUZHdmadmyZapZs6aaNm1qV2u1WpWYmKiQkBCVK1dOjRs31ieffGKb/tVXX8lisWj16tVq2rSpypUrp4ceekgZGRlatWqV6tWrJ19fXz399NO6ePGi3bKvXr2quLg4+fn5KSAgQKNGjdKvP80dHByssWPHqlevXvL19bWFpVdeeUX33HOPvL299X//938aNWqUrly5YptvzJgxatKkif7xj38oODhYfn5+euqpp/TLL784vF2O8vb2VmBgoGrUqKH7779f77zzjj744AN9+OGH+vLLL51eHoCSR7ADYDp9+vTR3LlzbcNz5sxR7969C9QlJiZqwYIFmjVrlvbt26chQ4bo2Wef1YYNG+zqxowZoxkzZmjTpk06ceKEunfvrilTpmjhwoX6/PPPtWbNGk2fPt1unvnz56tMmTLasmWLpk6dqsmTJ+ujjz6yq3n33XfVuHFj7dy5U6NGjZIk+fj4aN68edq/f7+mTp2qDz/8UO+9957dfEePHtXy5cu1cuVKrVy5Uhs2bND48eOd3q5bERMTo4oVK3JJFiitDAAwiZiYGKNbt25GRkaG4enpaRw/ftw4fvy44eXlZZw9e9bo1q2bERMTYxiGYVy+fNnw9vY2Nm3aZLeM2NhYo2fPnoZhGMb69esNScaXX35pm56YmGhIMo4ePWob9+KLLxpRUVG24bZt2xr16tUzrFarbdwrr7xi1KtXzzZcq1YtIzo6+ne3aeLEiUZYWJhtePTo0Ya3t7eRnZ1tGzds2DAjPDzc4e0qTNu2bY1BgwbdcPjXwsPDjc6dO/9u7wBuvzKuDpYAUNwqVaqkLl26aN68eTIMQ126dFFAQIBdzZEjR3Tx4kU9/PDDduPz8vIKXLJt1KiR7e9VqlSxXSb99bgtW7bYzXP//ffLYrHYhiMiIjRp0iTl5+fL3d1dktS8efMCvS9evFjTpk3T0aNHdeHCBV29elW+vr52NcHBwfLx8bENV61aVRkZGU5v160yDMNu2wCUHgQ7AKbUp08fxcXFSZJmzpxZYPqFCxckSZ9//rmqV69uN83T09NuuGzZsra/WywWu+Hr46xWq9M9li9f3m44JSVFzzzzjN544w1FRUXJz89PixYt0qRJk27Yz2/X78x23Yr8/HwdPnxYLVq0KPKyABQ/gh0AU+rUqZPy8vJksVgUFRVVYHr9+vXl6emp1NRUtW3bttjXv3nzZrvhb7/9VnXq1LGdrSvMpk2bVKtWLb3++uu2cT/++KNT6y3p7Zo/f75+/vlnPf7448W+bABFR7ADYEru7u46cOCA7e+/5ePjo6FDh2rIkCGyWq1q3bq1srKy9M0338jX11cxMTFFWn9qaqri4+P14osvaseOHZo+fXqBM2+/VadOHaWmpmrRokVq0aKFPv/8c3366adOrbc4t+vixYtKS0vT1atXdfLkSX366ad677331L9/f7Vv396pvgDcHgQ7AKb123vTfmvs2LGqVKmSEhMT9cMPP8jf31/NmjXTa6+9VuR19+rVS5cuXVLLli3l7u6uQYMG/e7737p27aohQ4YoLi5Oubm56tKli0aNGqUxY8Y4te7i2q4PP/xQH374oTw8PHT33XcrLCxMixcv1qOPPurUcgDcPhbD+NWLlQAAAPCHxXvsAAAATIJgBwAAYBIEOwAAAJMg2AEAAJgEwQ4AAMAkCHYAAAAmQbADAAAwCYIdAACASRDsAAAATIJgBwAAYBIEOwAAAJMg2AEAAJjE/wNXDaAdNAPyxAAAAABJRU5ErkJggg==", 168 | "text/plain": [ 169 | "
" 170 | ] 171 | }, 172 | "metadata": {}, 173 | "output_type": "display_data" 174 | }, 175 | { 176 | "data": { 177 | "image/png": "iVBORw0KGgoAAAANSUhEUgAAB8UAAAHvCAYAAADNQw6XAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjguNCwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy8fJSN1AAAACXBIWXMAAA9hAAAPYQGoP6dpAABqcElEQVR4nO3de5yWc/4/8Pd0mo4TUapVSgglZzYhh5Q2NuyybMhhsZQcvmtp1ymWif2uzVpy2FVOYe0KiyKHcowOsgopSi2S44yKQXP9/vDr/hpNzUxzuA/zfD4e92P3Ot7v+zOXeXXN+76uKy9JkiQAAAAAAAAAIAc1SHcBAAAAAAAAAFBbNMUBAAAAAAAAyFma4gAAAAAAAADkLE1xAAAAAAAAAHKWpjgAAAAAAAAAOUtTHAAAAAAAAICcpSkOAAAAAAAAQM7SFAcAAAAAAAAgZ2mKAwAAAAAAAJCzNMUBAABgHS699NLIy8ur1rYff/xxhevm5eXF8OHDN+h9atPUqVMjLy8vpk6dmpp3wgknRJcuXdJWEwAAAFSVpjgAAECWGT9+fOTl5UXTpk3jvffeW2v5fvvtFz179kxDZTVr1apVcemll5ZpyJKdXn/99bj00ktj8eLF6S4FAACAekhTHAAAIEuVlJTE6NGj011GrVm1alWMGjUqrU3xCy+8ML788su0vX8muuWWW2L+/PlV2ub111+PUaNGaYoDAACQFpriAAAAWWqnnXaKW265Jd5///10lxIREStXrkx3CTWuUaNG0bRp03SXUSNq6ufTuHHjyM/Pr5F9AQAAQF3QFAcAAMhSv/vd72L16tWVvlr8zjvvjF133TWaNWsWbdq0iaOPPjqWLl1aZp1nn302jjzyyOjcuXPk5+dHp06d4pxzzlnraukTTjghWrZsGW+//Xb85Cc/iVatWsWQIUMiIqK0tDTGjBkTPXr0iKZNm8Zmm20Wp512Wnz22Wdl9jFz5swYMGBAbLrpptGsWbPo2rVrnHTSSRERsXjx4mjbtm1ERIwaNSry8vIiLy8vLr300nV+vjW3lX/++efj3HPPjbZt20aLFi3i8MMPj48++mit9SdNmhT77LNPtGjRIlq1ahWDBg2KefPmlVmnvGeKf/nllzFixIjYdNNNo1WrVvHTn/403nvvvXXW9/nnn8cJJ5wQG220UbRu3TpOPPHEWLVqVbmf4a677oru3btH06ZNY9ddd41nnnlmrXVeeeWVGDhwYBQUFETLli3jwAMPjOnTp5c7FtOmTYszzjgj2rVrF5tvvvk6xy4i4r///W8cdthh0aJFi2jXrl2cc845UVJSstZ65T1T/J577oldd901WrVqFQUFBbHDDjvEtddem6rlyCOPjIiI/fffP/WzXHMHgAcffDAGDRoUHTt2jPz8/OjWrVtcfvnlsXr16jLvseaxAK+//nrsv//+0bx58/jRj34UV1999Vo1fvXVV3HppZfGNttsE02bNo0OHTrEEUccEW+//XZqnZo4TgEAAMgOjdJdAAAAABuma9eucfzxx8ctt9wSF1xwQXTs2HGd615xxRVx0UUXxVFHHRW/+tWv4qOPPorrrrsu9t1333jllVdio402ioiI++67L1atWhWnn356bLLJJvHyyy/HddddF//973/jvvvuK7PPb7/9NgYMGBB77713/O///m80b948IiJOO+20GD9+fJx44okxYsSIWLRoUfz1r3+NV155JZ5//vlo3LhxLF++PPr37x9t27aNCy64IDbaaKNYvHhx3H///RER0bZt2xg7dmycfvrpcfjhh8cRRxwRERG9evWqcFzOPPPM2HjjjeOSSy6JxYsXx5gxY2L48OFx7733pta54447YujQoTFgwIC46qqrYtWqVTF27NjYe++945VXXlmr6ft9J5xwQvzjH/+I4447Ln784x/HtGnTYtCgQetc/6ijjoquXbtGYWFhzJ49O/72t79Fu3bt4qqrriqz3rRp0+Lee++NESNGRH5+ftxwww1x8MEHx8svv5x6Rvy8efNin332iYKCgvjtb38bjRs3jptuuin222+/mDZtWuy5555l9nnGGWdE27Zt4+KLL17vleJffvllHHjggbFkyZIYMWJEdOzYMe6444546qmn1jfUERExZcqUOOaYY+LAAw9MfaY33ngjnn/++TjrrLNi3333jREjRsRf/vKX+N3vfhfbbbddRETqf8ePHx8tW7aMc889N1q2bBlPPfVUXHzxxVFcXBx//OMfy7zXZ599FgcffHAcccQRcdRRR8U///nPOP/882OHHXaIgQMHRkTE6tWr45BDDoknn3wyjj766DjrrLPiiy++iClTpsTcuXOjW7duEVEzxykAAABZIgEAACCrjBs3LomIZMaMGcnbb7+dNGrUKBkxYkRqed++fZMePXqkphcvXpw0bNgwueKKK8rs57XXXksaNWpUZv6qVavWer/CwsIkLy8veffdd1Pzhg4dmkREcsEFF5RZ99lnn00iIrnrrrvKzJ88eXKZ+RMnTkx9hnX56KOPkohILrnkkvWMxv9ZMy79+vVLSktLU/PPOeecpGHDhsnnn3+eJEmSfPHFF8lGG22UnHLKKWW2X7ZsWdK6desy8y+55JLk+6fOs2bNSiIiOfvss8tse8IJJ6xV65ptTzrppDLrHn744ckmm2xSZl5EJBGRzJw5MzXv3XffTZo2bZocfvjhqXmHHXZY0qRJk+Ttt99OzXv//feTVq1aJfvuu+9aY7H33nsn33777boH7f8bM2ZMEhHJP/7xj9S8lStXJltttVUSEcnTTz+dmj906NBkiy22SE2fddZZSUFBwXrf57777ltrP2uUd8yddtppSfPmzZOvvvoqNa9v375JRCS33357al5JSUnSvn375Gc/+1lq3q233ppERHLNNdestd81x0VNHqcAAABkPrdPBwAAyGJbbrllHHfccXHzzTfHBx98UO46999/f5SWlsZRRx0VH3/8cerVvn372HrrrePpp59OrdusWbPU/1+5cmV8/PHHsddee0WSJPHKK6+ste/TTz+9zPR9990XrVu3joMOOqjMe+26667RsmXL1HutuTL94Ycfjm+++aa6w1DGqaeeWuaW5/vss0+sXr063n333Yj47srmzz//PI455pgyNTZs2DD23HPPMuPxQ5MnT46I767A/r4zzzxzndv8+te/LjO9zz77xCeffBLFxcVl5vfu3Tt23XXX1HTnzp1j8ODB8dhjj8Xq1atj9erV8fjjj8dhhx0WW265ZWq9Dh06xC9/+ct47rnn1trnKaecEg0bNlxnbWs8+uij0aFDh/j5z3+emte8efM49dRTK9x2o402ipUrV8aUKVMqXLc83z/mvvjii/j4449jn332iVWrVsWbb75ZZt2WLVvGsccem5pu0qRJ7LHHHvHOO++k5v3rX/+KTTfdtNyfyZrjIhOOUwAAAOqOpjgAAECWu/DCC+Pbb79d57PFFyxYEEmSxNZbbx1t27Yt83rjjTdi+fLlqXWXLFkSJ5xwQrRp0yZatmwZbdu2jb59+0ZERFFRUZn9NmrUaK3nVC9YsCCKioqiXbt2a73XihUrUu/Vt2/f+NnPfhajRo2KTTfdNAYPHhzjxo0r9xnWVdW5c+cy0xtvvHFEROpZ0QsWLIiIiAMOOGCtGh9//PEy4/FD7777bjRo0CC6du1aZv5WW221wfWssfXWW6+17TbbbBOrVq2Kjz76KD766KNYtWpVdO/efa31tttuuygtLV3rGfE/rHNd3n333dhqq63Wen56ee/1Q2eccUZss802MXDgwNh8883jpJNOSn15oDLmzZsXhx9+eLRu3ToKCgqibdu2qcb3D4+5zTfffK0aN9544zJj+fbbb0f37t2jUaN1PzEuE45TAAAA6o5nigMAAGS5LbfcMo499ti4+eab44ILLlhreWlpaeTl5cWkSZPKvWq4ZcuWEfHds5gPOuig+PTTT+P888+PbbfdNlq0aBHvvfdenHDCCVFaWlpmu/z8/GjQoOx3rUtLS6Ndu3Zx1113lVtr27ZtI+K7K3b/+c9/xvTp0+Pf//53PPbYY3HSSSfFn/70p5g+fXqqpg2xriujkyRJ1Rjx3XPF27dvv9Z662um1kY9ten7V2HXlnbt2sWcOXPisccei0mTJsWkSZNi3Lhxcfzxx8dtt9223m0///zz6Nu3bxQUFMRll10W3bp1i6ZNm8bs2bPj/PPPX+uYq6mxzITjFAAAgLqjKQ4AAJADLrzwwrjzzjvjqquuWmtZt27dIkmS6Nq1a2yzzTbr3Mdrr70Wb731Vtx2221x/PHHp+ZX5bbY3bp1iyeeeCL69OlTqYbsj3/84/jxj38cV1xxRUyYMCGGDBkS99xzT/zqV79a64rgmtKtW7eI+K6Z269fvyptu8UWW0RpaWksWrSozJXdCxcurHZda65g/7633normjdvnmrSNm/ePObPn7/Wem+++WY0aNAgOnXqtEHvvcUWW8TcuXMjSZIy417ee5WnSZMmceihh8ahhx4apaWlccYZZ8RNN90UF110UblXoK8xderU+OSTT+L++++PfffdNzV/0aJFG/Q5Ir77+b700kvxzTffROPGjde5Tk0dpwAAAGQ+t08HAADIAd26dYtjjz02brrppli2bFmZZUcccUQ0bNgwRo0atdYVtUmSxCeffBIR/3cV7vfXSZIkrr322krXcdRRR8Xq1avj8ssvX2vZt99+G59//nlEfHfr8B/WstNOO0VEpG5N3bx584iI1DY1ZcCAAVFQUBBXXnlluc+J/uijj9a7bUTEDTfcUGb+ddddV+26XnzxxZg9e3ZqeunSpfHggw9G//79o2HDhtGwYcPo379/PPjgg7F48eLUeh9++GFMmDAh9t577ygoKNig9/7JT34S77//fvzzn/9MzVu1alXcfPPNFW675vhZo0GDBtGrV6+I+L+fZYsWLSJi7Z9lecfc119/vdb4VsXPfvaz+Pjjj+Ovf/3rWsvWvE9NHqcAAABkPleKAwAA5Ijf//73cccdd8T8+fOjR48eqfndunWLP/zhDzFy5MhYvHhxHHbYYdGqVatYtGhRTJw4MU499dT4zW9+E9tuu21069YtfvOb38R7770XBQUF8a9//WutZ1+vT9++feO0006LwsLCmDNnTvTv3z8aN24cCxYsiPvuuy+uvfba+PnPfx633XZb3HDDDXH44YdHt27d4osvvohbbrklCgoK4ic/+UlEfHfr7+233z7uvffe2GabbaJNmzbRs2fP6NmzZ7XGqaCgIMaOHRvHHXdc7LLLLnH00UdH27ZtY8mSJfHII49Enz59ym2oRkTsuuuu8bOf/SzGjBkTn3zySfz4xz+OadOmxVtvvRURUa2r23v27BkDBgyIESNGRH5+fqoxPGrUqNQ6f/jDH2LKlCmx9957xxlnnBGNGjWKm266KUpKSuLqq6/e4Pc+5ZRT4q9//Wscf/zxMWvWrOjQoUPccccdqS8mrM+vfvWr+PTTT+OAAw6IzTffPN5999247rrrYqeddortttsuIr5rJDds2DCuuuqqKCoqivz8/DjggANir732io033jiGDh0aI0aMiLy8vLjjjjuqdWv5448/Pm6//fY499xz4+WXX4599tknVq5cGU888UScccYZMXjw4Bo9TgEAAMh8muIAAAA5Yquttopjjz223Oc4X3DBBbHNNtvEn//851STtVOnTtG/f//46U9/GhERjRs3jn//+98xYsSIKCwsjKZNm8bhhx8ew4cPjx133LHSddx4442x6667xk033RS/+93volGjRtGlS5c49thjo0+fPhHxXfP85ZdfjnvuuSc+/PDDaN26deyxxx5x1113RdeuXVP7+tvf/hZnnnlmnHPOOfH111/HJZdcUu2meETEL3/5y+jYsWOMHj06/vjHP0ZJSUn86Ec/in322SdOPPHE9W57++23R/v27ePuu++OiRMnRr9+/eLee++N7t27R9OmTTe4pr59+0bv3r1j1KhRsWTJkth+++1j/PjxqauuIyJ69OgRzz77bIwcOTIKCwujtLQ09txzz7jzzjtjzz333OD3bt68eTz55JNx5plnxnXXXRfNmzePIUOGxMCBA+Pggw9e77Zrnmd/ww03xOeffx7t27ePX/ziF3HppZemnjnfvn37uPHGG6OwsDBOPvnkWL16dTz99NOx3377xcMPPxz/8z//ExdeeGFsvPHGceyxx8aBBx6Yuiq/qho2bBiPPvpo6lbn//rXv2KTTTaJvffeO3bYYYfUejV5nAIAAJDZ8pLqfP0aAAAAiDlz5sTOO+8cd955ZwwZMiTd5QAAAADf45niAAAAUAVffvnlWvPGjBkTDRo0iH333TcNFQEAAADr4/bpAAAAUAVXX311zJo1K/bff/9o1KhRTJo0KSZNmhSnnnpqdOrUKd3lAQAAAD/g9ukAAABQBVOmTIlRo0bF66+/HitWrIjOnTvHcccdF7///e+jUSPfPQcAAIBMoykOAAAAAAAAQM7yTHEAAAAAAAAAcpamOAAAAAAAAAA5S1McAAAAAAAAgJylKQ4AAAAAAABAztIUBwAAAAAAACBnaYoDAAAAAAAAkLM0xQEAAAAAAADIWZriAAAAAAAAAOQsTXEAAAAAAAAAcpamOAAAAAAAAAA5S1McAAAAAAAAgJylKQ4AAAAAAABAztIUBwAAAAAAACBnaYoDAAAAAAAAkLM0xQEAAAAAAADIWZriAAAAAAAAAOQsTXEAAAAAAAAAcpamOAAAAAAAAAA5q1G6C6htpaWl8f7770erVq0iLy8v3eUAZL0kSeKLL76Ijh07RoMGufHdKlkBULNkBQCVIS8AqIisAKAilc2KnG+Kv//++9GpU6d0lwGQc5YuXRqbb755usuoEbICoHbICgAqQ14AUBFZAUBFKsqKnG+Kt2rVKiK+G4iCgoI0VwOQ/YqLi6NTp06p36+5QFYA1CxZAUBlyAsAKiIrAKhIZbMi55via24/UlBQIGAAalAu3d5JVgDUDlkBQGXICwAqIisAqEhFWZEbD+EAAAAAAAAAgHJoigMAAAAAAACQszTFAQAAAAAAAMhZmuIAAAAAAAAA5CxNcQAAAAAAAABylqY4AAAAAAAAADlLUxwAAAAAAACAnKUpDgAAAAAAAEDO0hQHAAAAAAAAIGdpigMAAAAAAACQszTFAQAAAAAAAMhZmuIAZL3Vq1fHRRddFF27do1mzZpFt27d4vLLL48kSdJdGgAZQlYAUJEuXbpEXl7eWq9hw4aluzQAMoTzCoDs1SjdBQBAdV111VUxduzYuO2226JHjx4xc+bMOPHEE6N169YxYsSIdJcHQAaQFQBUZMaMGbF69erU9Ny5c+Oggw6KI488Mo1VAZBJnFcAZC9NcQCy3gsvvBCDBw+OQYMGRcR3V3jcfffd8fLLL6e5MgAyhawAoCJt27YtMz169Ojo1q1b9O3bN00VAZBpnFcAZC+3Twcg6+21117x5JNPxltvvRUREa+++mo899xzMXDgwDRXBkCmkBUAVMXXX38dd955Z5x00kmRl5eX7nIAyBDOKwCylyvFAch6F1xwQRQXF8e2224bDRs2jNWrV8cVV1wRQ4YMKXf9kpKSKCkpSU0XFxfXVakApImsAKAqHnjggfj888/jhBNOWO968gKgfqnqeUWErADIFJrikEG6XPBIrex38ehBtbJfyBT/+Mc/4q677ooJEyZEjx49Ys6cOXH22WdHx44dY+jQoWutX1hYGKNGjUpDpWQiv3uhfpAVVIesgPrn73//ewwcODA6duy43vXkBayttnKztshjqqKq5xURsoK64ZwFKub26QBkvfPOOy8uuOCCOProo2OHHXaI4447Ls4555woLCwsd/2RI0dGUVFR6rV06dI6rhiAuiYrAKisd999N5544on41a9+VeG68gKgfqnqeUWErADIFK4UByDrrVq1Kho0KPs9r4YNG0ZpaWm56+fn50d+fn5dlAZAhpAVAFTWuHHjol27djFoUMVXRskLgPqlqucVEbICIFNoigOQ9Q499NC44ooronPnztGjR4945ZVX4pprromTTjop3aUBkCFkBQCVUVpaGuPGjYuhQ4dGo0b+bAZAWc4rALKXf90DkPWuu+66uOiii+KMM86I5cuXR8eOHeO0006Liy++ON2lAZAhZAUAlfHEE0/EkiVLNDcAKJfzCoDspSkOQNZr1apVjBkzJsaMGZPuUgDIULICgMro379/JEmS7jIAyFDOKwCyV4OKVwEAAAAAAACA7KQpDgAAAAAAAEDO0hQHAAAAAAAAIGdpigMAAAAAAACQszTFAQAAAAAAAMhZmuIAAAAAAAAA5CxNcQAAAAAAAABylqY4AAAAAAAAADlLUxwAAAAAAACAnKUpDgAAAAAAAEDO0hQHAAAAAAAAIGdpigMAAAAAAACQszTFAQAAAAAAAMhZmuIAAAAAAAAA5Ky0NsWfeeaZOPTQQ6Njx46Rl5cXDzzwwDrX/fWvfx15eXkxZsyYOqsPAAAAAAAAgOyW1qb4ypUrY8cdd4zrr79+vetNnDgxpk+fHh07dqyjygAAAAAAAADIBY3S+eYDBw6MgQMHrned9957L84888x47LHHYtCgQXVUGQAAAAAAAAC5IKOfKV5aWhrHHXdcnHfeedGjR490lwMAAAAAAABAlknrleIVueqqq6JRo0YxYsSISm9TUlISJSUlqeni4uLaKA0AAAAAAACALJCxTfFZs2bFtddeG7Nnz468vLxKb1dYWBijRo2qxcrIJl0ueKRW9rt4tFv5AwAAAAAAQDbI2NunP/vss7F8+fLo3LlzNGrUKBo1ahTvvvtu/M///E906dJlnduNHDkyioqKUq+lS5fWXdEAAAAAAAAAZJSMvVL8uOOOi379+pWZN2DAgDjuuOPixBNPXOd2+fn5kZ+fX9vlAQAAAAAAAJAF0toUX7FiRSxcuDA1vWjRopgzZ060adMmOnfuHJtsskmZ9Rs3bhzt27eP7t2713WpAAAAAAAAAGShtDbFZ86cGfvvv39q+txzz42IiKFDh8b48ePTVBUAAAAAAAAAuSKtTfH99tsvkiSp9PqLFy+uvWIAAAAAAAAAyDkN0l0AAAAAAAAAANQWTXEAAAAAAAAAcpamOAAAAAAAAAA5S1McAAAAAAAAgJylKQ4AAAAAAABAztIUBwAAAAAAACBnaYoDAAAAAAAAkLM0xQEAAAAAAADIWZriAAAAAAAAAOQsTXEAAAAAAAAAcpamOAAAAAAAAAA5S1McgKzXpUuXyMvLW+s1bNiwdJcGQIaQFQAAQE1wbgGQnRqluwAAqK4ZM2bE6tWrU9Nz586Ngw46KI488sg0VgVAJpEVAABATXBuAZCdNMUByHpt27YtMz169Ojo1q1b9O3bN00VAZBpZAUAAFATnFsAZCe3Twcgp3z99ddx5513xkknnRR5eXnpLgeADCQrAACAmuDcAiB7uFIcgJzywAMPxOeffx4nnHDCOtcpKSmJkpKS1HRxcXEdVAZAppAVAABATXBuAZA9NMUByCl///vfY+DAgdGxY8d1rlNYWBijRo2qw6oAyCSyglzX5YJHam3fi0cPqrV9ZxNjDABEOLcg99XWv3v9m5d0cPt0AHLGu+++G0888UT86le/Wu96I0eOjKKiotRr6dKldVQhAOkmKwAAgJrg3AIgu2iKA5Azxo0bF+3atYtBg9b/TcP8/PwoKCgo8wKgfpAVAKzPe++9F8cee2xssskm0axZs9hhhx1i5syZ6S4LgAzk3AIgu7h9OmyA2rxVHrBhSktLY9y4cTF06NBo1Ei8AbA2WQHA+nz22WfRp0+f2H///WPSpEnRtm3bWLBgQWy88cbpLg2ADOPcAiD7+G0NQE544oknYsmSJXHSSSeluxQAMpSsAGB9rrrqqujUqVOMGzcuNa9r165prAiATOXcAiD7aIoDkBP69+8fSZKkuwwAMpisAGB9HnrooRgwYEAceeSRMW3atPjRj34UZ5xxRpxyyinr3KakpCRKSkpS08XFxXVRKgBp5twCIPt4pjgAAAAA9d4777wTY8eOja233joee+yxOP3002PEiBFx2223rXObwsLCaN26derVqVOnOqwYAACoLE1xAAAAAOq90tLS2GWXXeLKK6+MnXfeOU499dQ45ZRT4sYbb1znNiNHjoyioqLUa+nSpXVYMQAAUFma4gAAAADUex06dIjtt9++zLztttsulixZss5t8vPzo6CgoMwLAADIPJriAAAAANR7ffr0ifnz55eZ99Zbb8UWW2yRpooAAICaoikOAAAAQL13zjnnxPTp0+PKK6+MhQsXxoQJE+Lmm2+OYcOGpbs0AACgmjTFAQAAAKj3dt9995g4cWLcfffd0bNnz7j88stjzJgxMWTIkHSXBgAAVFOjdBcAAAAAAJngkEMOiUMOOSTdZQAAADXMleIAAAAAAAAA5CxNcQAAAAAAAABylqY4AAAAAAAAADlLUxwAAAAAAACAnKUpDgAAAAAAAEDO0hQHAAAAAAAAIGdpigMAAAAAAACQszTFAQAAAAAAAMhZmuIAAAAAAAAA5CxNcQAAAAAAAAByVlqb4s8880wceuih0bFjx8jLy4sHHnggteybb76J888/P3bYYYdo0aJFdOzYMY4//vh4//3301cwAAAAAAAAAFklrU3xlStXxo477hjXX3/9WstWrVoVs2fPjosuuihmz54d999/f8yfPz9++tOfpqFSAAAAAAAAALJRo3S+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YxURMW3atBg2bFhMnz49pkyZEt988030798/Vq5cmVrnnHPOiX//+99x3333xbRp0+L999+PI444IrV89erVMWjQoPj666/jhRdeiNtuuy3Gjx8fF198cTo+Uq2qzHhFRJxyyilljrGrr746taw+jRd1S1ZUjbyoPFlRNbKCTCYrqkZWVJ6sqBpZQSaTFVUjK6pGXlSerCDTyYvKkxVVIyuqRl5UICGrRUQyceLE1HRpaWnSvn375I9//GNq3ueff57k5+cnd999d5IkSfL6668nEZHMmDEjtc6kSZOSvLy85L333quz2uvaD8cqSZJk6NChyeDBg9e5TX0dqzWWL1+eREQybdq0JEm+O5YaN26c3Hfffal13njjjSQikhdffDFJkiR59NFHkwYNGiTLli1LrTN27NikoKAgKSkpqdsPUMd+OF5JkiR9+/ZNzjrrrHVuU5/Hi7ojK6pGXlSNrKgaWUGmkhVVIyuqRlZUjawgU8mKqpEVVScvKk9WkMnkReXJiqqTFVUjL8pypXiOWbRoUSxbtiz69euXmte6devYc88948UXX4yIiBdffDE22mij2G233VLr9OvXLxo0aBAvvfRSndecblOnTo127dpF9+7d4/TTT49PPvkktay+j1VRUVFERLRp0yYiImbNmhXffPNNmeNr2223jc6dO5c5vnbYYYfYbLPNUusMGDAgiouLY968eXVYfd374Xitcdddd8Wmm24aPXv2jJEjR8aqVatSy+rzeJE+smLDyIvyyYqqkRVkC1mxYWRF+WRF1cgKsoWs2DCyYt3kReXJCrKJvKg6WbFusqJq5EVZjdJdADVr2bJlERFlDtY102uWLVu2LNq1a1dmeaNGjaJNmzapdeqLgw8+OI444ojo2rVrvP322/G73/0uBg4cGC+++GI0bNiwXo9VaWlpnH322dGnT5/o2bNnRHx37DRp0iQ22mijMuv+8Pgq7/hbsyxXlTdeERG//OUvY4sttoiOHTvGf/7znzj//PNj/vz5cf/990dE/R0v0ktWVJ28KJ+sqBpZQTaRFVUnK8onK6pGVpBNZEXVyYp1kxeVJyvINvKiamTFusmKqpEXa9MUp147+uijU/9/hx12iF69ekW3bt1i6tSpceCBB6axsvQbNmxYzJ07N5577rl0l5IV1jVe33+Wyw477BAdOnSIAw88MN5+++3o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lvq22g+/hTRr1qz1vm9ExE033RSdOnWK5s2bx1FHHRVFRUWpZaWlpXHZZZfF5ptvHvn5+bHTTjvF5MmTU8vXfPvp3nvvjb59+0bTpk3jrrvuKncsFixYEPvuu280bdo0tt9++5gyZUqZ5T/8JtWGjP+MGTPioIMOik033TRat24dffv2jdmzZ5d5n7y8vPjb3/4Whx9+eDRv3jy23nrreOihh8qsM2/evDjkkEOioKAgWrVqFfvss0+8/fbbqeV/+9vfYrvttoumTZvGtttuGzfccEO5n3mNRx99NPLz8+PHP/7xWsvGjRsXhxxySJx++ulx9913x5dffllm+X777RcjRoyI3/72t9GmTZto3759XHrppWXWefPNN2PvvfdOje0TTzxR5pj5+uuvY/jw4dGhQ4do2rRpbLHFFqn/7tZ8C+zwww+PvLy81PSaW9X87W9/i65du0bTpk0j4rtAHDx4cLRs2TIKCgriqKOOig8//DBVy5rtbr311ujcuXO0bNkyzjjjjFi9enVcffXV0b59+2jXrl1cccUVZT7DxhtvHH369Il77rlnvWNJ/SMr/o+skBWyQlZQPlnxf2SFrJAVsoJ1kxf/R17IC3khLyifrPg/skJWyIocyoqEtBo6dGgyePDg5P7770+aNm2aLF26NEmSJJk4cWLy/R/PwoULkxYtWiR//vOfk7feeit5/vnnk5133jk54YQTUuv8/e9/Tx599NHk7bffTl588cWkd+/eycCBA1PLn3766SQikl69eiWPP/54snDhwuSTTz5J/vCHPyTbbrttMnny5OTtt99Oxo0bl+Tn5ydTp05NkiRJhg0bluy0007JjBkzkkWLFiVTpkxJHnrooSRJkuTll19OIiJ54oknkg8++CD55JNPyv2ca957zz33TKZOnZrMmzcv2WeffZK99tortc4zzzyTFBQUJOPHj0/efvvt5PHHH0+6dOmSXHrppal1IiKZOHFikiRJUlRUlLRp0yY59thjk3nz5iWPPvposs022yQRkbzyyiuVft9LLrkkadGiRXLAAQckr7zySjJt2rRkq622Sn75y1+m1rnmmmuSgoKC5O67707efPPN5Le//W3SuHHj5K233kqSJEkWLVqURETSpUuX5F//+lfyzjvvJO+///5a47B69eqkZ8+eyYEHHpjMmTMnmTZtWrLzzjuX+Vxr9rXmM2zI+D/55JPJHXfckbzxxhvJ66+/npx88snJZpttlhQXF5cZy8033zyZMGFCsmDBgmTEiBFJy5YtU/v473//m7Rp0yY54ogjkhkzZiTz589Pbr311uTNN99MkiRJ7rzzzqRDhw6pz/uvf/0radOmTTJ+/Phyj4EkSZIRI0YkBx988FrzS0tLky222CJ5+OGHkyRJkl133TW5/fbby6zTt2/fpKCgILn00kuTt956K7ntttuSvLy85PHHH0+SJEm+/fbbpHv37slBBx2UzJkzJ3n22WeTPfbYo8zY/vGPf0w6deqUPPPMM8nixYuTZ599NpkwYUKSJEmyfPnyJCKScePGJR988EGyfPnyMsfHwQcfnMyePTt59dVXk9WrVyc77bRTsvfeeyczZ85Mpk+fnuy6665J3759yxxXLVu2TH7+858n8+bNSx566KGkSZMmyYABA5IzzzwzefPNN5Nbb701iYhk+vTpZT7r+eefX2ZfICtkhayQFbKCisgKWSErZIWsoDLkhbyQF/JCXlARWSErZIWsyNWs0BRPszUBkyRJ8uMf/zg56aSTkiRZO2BOPvnk5NRTTy2z7bPPPps0aNAg+fLLL8vd94wZM5KISL744oskSf7vl+0DDzyQWuerr75Kmjdvnrzwwgtltj355JOTY445JkmSJDn00EOTE088sdz3+OEvw3VZ895PPPFEat4jjzySRESq/gMPPDC58sory2x3xx13JB06dEhNf/+XxdixY5NNNtmkzOe/5ZZbyg2Y9b3vJZdckjRs2DD573//m1pn0qRJSYMGDZIPPvggSZIk6dixY3LFFVeUqW333XdPzjjjjDLjMGbMmPWOw2OPPZY0atQoee+998q81/oCpibGf/Xq1UmrVq2Sf//736l5EZFceOGFqekVK1YkEZFMmjQpSZIkGTlyZNK1a9fk66+/Lnef3bp1S/1yXuPyyy9Pevfuvc46Bg8enDrGv+/xxx9P2rZtm3zzzTdJkiTJn//857V+wfbt2zfZe++9y8zbfffdk/PPPz9Jku/GsVGjRqmfWZIkyZQpU8qM7ZlnnpkccMABSWlpabn1fX/dNS655JKkcePGqcBZU2/Dhg2TJUuWpObNmzcviYjk5ZdfTm3XvHnzMqE+YMCApEuXLsnq1atT87p3754UFhaWec9rr7026dKlS7k1Uj/JClkhK2SFrKAiskJWyApZISuoDHkhL+SFvJAXVERWyApZIStyNSvcPj2DXHXVVXHbbbfFG2+8sdayV199NcaPHx8tW7ZMvQYMGBClpaWxaNGiiPjudhuHHnpodO7cOVq1ahV9+/aNiO9umfB9u+22W+r/L1y4MFatWhUHHXRQmX3ffvvtqVtOnH766XHPPffETjvtFL/97W/jhRde2ODP2KtXr9T/79ChQ0RE6nYpr776alx22WVl6jjllFPigw8+iFWrVq21r/nz50evXr1St4aIiNhjjz2q/L4REZ07d44f/ehHqenevXtHaWlpzJ8/P4qLi+P999+PPn36lNlnnz591vpZfX9sy/PGG29Ep06domPHjmXea302ZPw//PDDOOWUU2LrrbeO1q1bR0FBQaxYsWKtY+H749KiRYsoKChIjcucOXNin332icaNG6+1/5UrV8bbb78dJ598cpmf1x/+8Icytyr5oS+//LLMz2uNW2+9NX7xi1+knklyzDHHxPPPP7/Wvr5fb8R3P8s19c6fPz86deoU7du3Ty3/4fFwwgknxJw5c6J79+4xYsSIePzxx9dZ6/dtscUW0bZt29T0mp9jp06dUvO233772GijjcocE126dIlWrVqlpjfbbLPYfvvto0GDBmXmff9YjIho1qxZucc8RMgKWVE+WVF+vRGygvpJVsiK8siK8uuNkBXUX/JCXpRHXpRfb4S8oH6SFbKiPLKi/HojZEWma5TuAvg/++67bwwYMCBGjhwZJ5xwQpllK1asiNNOOy1GjBix1nadO3eOlStXxoABA2LAgAFx1113Rdu2bWPJkiUxYMCA+Prrr8us36JFizL7jYh45JFHyvyCjYjIz8+PiIiBAwfGu+++G48++mhMmTIlDjzwwBg2bFj87//+b5U/4/d/WeXl5UXEd8++WFPLqFGj4ogjjlhru/J+KdXU+9ak749tTdmQ8R86dGh88sknce2118YWW2wR+fn50bt377WOhR+GR15eXmpcmjVrts79rzlubrnllthzzz3LLGvYsOE6t9t0003js88+KzPv008/jYkTJ8Y333wTY8eOTc1fvXp13HrrrWWeX7G+eitjl112iUWLFsWkSZPiiSeeiKOOOir69etX5nk05dnQn2t59VbmM3z66adlAg2+T1bIivLIClkB3ycrZEV5ZIWsgB+SF/KiPPJCXsD3yQpZUR5ZISuylaZ4hhk9enTstNNO0b179zLzd9lll3j99ddjq622Kne71157LT755JMYPXp06psgM2fOrPD9tt9++8jPz48lS5akvqVVnrZt28bQoUNj6NChsc8++8R5550X//u//xtNmjSJiO9+GVTXLrvsEvPnz1/nZ/yh7t27x5133hklJSWpMJwxY8YGvfeSJUvi/fffT30bavr06dGgQYPo3r17FBQURMeOHeP5558vM0bPP//8Or/ltS7bbbddLF26ND744IPUt7+mT59e4XZVHf/nn38+brjhhvjJT34SERFLly6Njz/+uEq19urVK2677bb45ptv1vqluNlmm0XHjh3jnXfeiSFDhlR6nzvvvHPceeedZebdddddsfnmm8cDDzxQZv7jjz8ef/rTn+Kyyy5bb2it0b1791i6dGl8+OGHsdlmm0VE+cdDQUFB/OIXv4hf/OIX8fOf/zwOPvjg+PTTT6NNmzbRuHHjSh3La36OS5cuTf339vrrr8fnn38e22+/fYXbV2Tu3Lmx8847V3s/5C5ZISvKIytkBXyfrJAV5ZEVsgJ+SF7Ii/LIC3kB3ycrZEV5ZIWsyEZun55hdthhhxgyZEj85S9/KTP//PPPjxdeeCGGDx8ec+bMiQULFsSDDz4Yw4cPj4jvvnnVpEmTuO666+Kdd96Jhx56KC6//PIK369Vq1bxm9/8Js4555y47bbb4u23347Zs2fHddddF7fddltERFx88cXx4IMPxsKFC2PevHnx8MMPx3bbbRcREe3atYtmzZrF5MmT48MPP4yioqIN/uwXX3xx3H777TFq1KiYN29evPHGG3HPPffEhRdeWO76v/zlL6O0tDROPfXUeOONN+Kxxx5LfRNpzTerKqtp06YxdOjQePXVV+PZZ5+NESNGxFFHHZW6rcV5550XV111Vdx7770xf/78uOCCC2LOnDlx1llnVel9+vXrF9tss02Z9/r973+/3m02ZPy33nrruOOOO+KNN96Il156KYYMGbLeb1GVZ/jw4VFcXBxHH310zJw5MxYsWBB33HFHzJ8/PyIiRo0aFYWFhfGXv/wl3nrrrXjttddi3Lhxcc0116xznwMGDIh58+aV+ebV3//+9/j5z38ePXv2LPM6+eST4+OPP47JkydXqt6DDjoounXrFkOHDo3//Oc/8fzzz6eOnTXHwzXXXBN33313vPnmm/HWW2/FfffdF+3bt4+NNtooIr67dciTTz4Zy5YtW+vbYd/Xr1+/1H+rs2fPjpdffjmOP/746Nu3b4W3o6mMZ599Nvr371/t/ZC7ZIWs+CFZISvgh2SFrPghWSEroDzyQl78kLyQF/BDskJW/JCskBVZK90PNa/vhg4dmgwePLjMvEWLFiVNmjRJfvjjefnll5ODDjooadmyZdKiRYukV69eyRVXXJFaPmHChKRLly5Jfn5+0rt37+Shhx5KIiJ55ZVXkiRJkqeffjqJiOSzzz4rs9/S0tJkzJgxSffu3ZPGjRsnbdu2TQYMGJBMmzYtSZIkufzyy5PtttsuadasWdKmTZtk8ODByTvvvJPa/pZbbkk6deqUNGjQIOnbt2+5n7O8937llVeSiEgWLVqUmjd58uRkr732Spo1a5YUFBQke+yxR3LzzTenlkdEMnHixNT0888/n/Tq1Stp0qRJsuuuuyYTJkxIIiJ58803K/2+l1xySbLjjjsmN9xwQ9KxY8ekadOmyc9//vPk008/TW2zevXq5NJLL01+9KMfJY0bN0523HHHZNKkSWV+Zt8f6/WZP39+svfeeydNmjRJttlmm2Ty5MllPtcP97Uh4z979uxkt912S5o2bZpsvfXWyX333ZdsscUWyZ///Od1jmWSJEnr1q2TcePGpaZfffXVpH///knz5s2TVq1aJfvss0/y9ttvp5bfddddyU477ZQ0adIk2XjjjZN99903uf/++9f7+ffYY4/kxhtvTJIkSWbOnJlERPLyyy+Xu+7AgQOTww8/PEmSJOnbt29y1llnlVk+ePDgZOjQoanpN954I+nTp0/SpEmTZNttt03+/e9/JxGRTJ48OUmSJLn55puTnXbaKWnRokVSUFCQHHjggcns2bNT2z/00EPJVlttlTRq1CjZYostkiT5v+Pjh959993kpz/9adKiRYukVatWyZFHHpksW7Ystby87cr77/2Hn+uFF15INtpoo2TVqlXljgn1k6yQFbJCVsgKKiIrZIWskBWygsqQF/JCXsgLeUFFZIWskBWyIlezIi9JkmQD++mQce6666448cQTo6ioqMrfMqJuPPLII3HeeefF3Llzo0GD2r1ZxfPPPx977713LFy4MLp161ar71VTfvGLX8SOO+4Yv/vd79JdCuQsWZH5ZMX6yQqofbIi88mK9ZMVUDfkReaTF+snL6D2yYrMJyvWL5eywjPFyWq33357bLnllvGjH/0oXn311Tj//PPjqKOOEi4ZbNCgQbFgwYJ47733Us+2qCkTJ06Mli1bxtZbbx0LFy6Ms846K/r06ZM14fL111/HDjvsEOecc066S4GcIiuyj6xYN1kBtUNWZB9ZsW6yAmqPvMg+8mLd5AXUDlmRfWTFuuVaVrhSnKx29dVXxw033BDLli2LDh06xGGHHRZXXHFFNG/ePN2lkQa33357/OEPf4glS5bEpptuGv369Ys//elPsckmm6S7NCCNZAXfJyuA8sgKvk9WAOsiL/g+eQGUR1bwfbIis2iKAwAAAAAAAJCzavfm+AAAAAAAAACQRpriAAAAAAAAAOQsTXEAAAAAAAAAcpamOAAAAAAAAAA5S1McAAAAAAAAgJylKQ4AAAAAAABAztIUBwAAAAAAACBnaYoDAAAAAAAAkLM0xQEAAAAAAADIWf8PxSSwjXLRSS4AAAAASUVORK5CYII=", 178 | "text/plain": [ 179 | "
" 180 | ] 181 | }, 182 | "metadata": {}, 183 | "output_type": "display_data" 184 | }, 185 | { 186 | "data": { 187 | "image/png": "iVBORw0KGgoAAAANSUhEUgAAB8UAAAHvCAYAAADNQw6XAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjguNCwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy8fJSN1AAAACXBIWXMAAA9hAAAPYQGoP6dpAAB27ElEQVR4nOzde3wV9Zk/8CcECIgQLnKtIIgXBNSqoKtYxYoXFi/Yrq4Wu6D1Hi/oaiVuUYKVYNt1qdbiZS3QnyJqK+pq1aIVrKsoF7GiFqGCpFZEqyQCGjWZ3x8uqccAOUCSk5y836/XvNqZ+c6cJ99zwofjw8zkJEmSBAAAAAAAAABkoWaZLgAAAAAAAAAA6oqmOAAAAAAAAABZS1McAAAAAAAAgKylKQ4AAAAAAABA1tIUBwAAAAAAACBraYoDAAAAAAAAkLU0xQEAAAAAAADIWpriAAAAAAAAAGQtTXEAAAAAAAAAspamOAAAQIZMmDAhcnJyMl3GDsnJyYkJEyZkuowd9v/+3/+Lfv36RYsWLaJ9+/ZbHDdmzJjYeeed668wttmOfCZzcnLi4osvrnHcpt/dDz74YLteBwAAgPqlKQ4AALCNXnvttTjzzDPjG9/4RuTl5UWPHj1i1KhR8dprr2W6tGr+9re/xYQJE2LJkiXbfY7f/e53WdH43pI///nPMWbMmOjbt2/ceeedcccdd2S6pEYr2z8rAAAANE7NM10AAABAY/Lggw/GGWecER07dowf/OAH0adPn1i1alXcdddd8Zvf/CZmzZoVp5xySlrn+tGPfhTjxo2r03r/9re/RVFRUfTu3Tu++c1vbtc5fve738Wtt9662WbnJ598Es2bN+6vlnPnzo3Kysr4+c9/HnvssUemy2nUtvZZqS/Z8JkEAACgdvmWCAAAkKa//OUv8f3vfz923333ePbZZ6Nz585V+y677LL41re+Fd///vfjT3/6U+y+++5bPM+GDRuiTZs20bx580bfvGvVqlWmS9hha9eujYjY6m3Ts9EXX3wRlZWV0bJly0yXUquy4TMZEZEkSXz66afRunXrTJcCAADQ6Ll9OgAAQJp++tOfxsaNG+OOO+5IaYhHROyyyy5x++23x4YNG+InP/lJ1fZNzx5+/fXX43vf+1506NAhDj/88JR9X3f33XfHQQcdFK1bt46OHTvG6aefHiUlJSljhg4dGgMHDozXX389jjrqqNhpp53iG9/4Rsprz507NwYPHhwREWeddVbk5ORETk5OTJ8+PSIi/vjHP8app54avXr1iry8vOjZs2dcfvnl8cknn1SdY8yYMXHrrbdGRFQd/9WaN/f85pdffjmGDx8e7dq1i5133jmOPvromD9/fsqY6dOnR05OTvzv//5vXHHFFdG5c+do06ZNnHLKKfH++++njF24cGEcd9xxscsuu0Tr1q2jT58+cfbZZ1d/gzbjl7/8ZQwYMKDqNvcFBQWxbt26qv29e/eO6667LiIiOnfuvF3Po16yZEl07tw5hg4dGuvXr9/q2E3PrH7ooYdi4MCBkZeXFwMGDIgnnnii2th33nknzj777OjatWvVuF/96lcpYz777LO49tpr46CDDor8/Pxo06ZNfOtb34pnnnkmZdyqVasiJycnfvazn8WUKVOib9++kZeXF6+//npEfHkL+X/5l3+Jjh07RqtWrWLQoEHxyCOPpJzj888/j6Kiothzzz2jVatW0alTpzj88MNjzpw5EVHzZ2VzevfuHSeccEI899xzcfDBB0erVq1i9913j1//+tfVxq5bty7Gjh0bPXv2jLy8vNhjjz3ixhtvjMrKympz/PX3cO7cuTFo0KBo1apV9O3bN26//fYt/v5FRFrvT0TEBx98EKeddlq0a9cuOnXqFJdddll8+umnKWO++OKLuP7666vmvHfv3nHNNddEeXn5ZufiySefjEGDBkXr1q3j9ttvj4iIOXPmxOGHHx7t27ePnXfeOfbee++45pprtjq3AAAA/EPjviQBAACgHv3P//xP9O7dO771rW9tdv8RRxwRvXv3jscee6zavlNPPTX23HPPmDRpUiRJssXXuOGGG2L8+PFx2mmnxTnnnBPvv/9+3HLLLXHEEUfEyy+/nHI180cffRTHH398fOc734nTTjstfvOb38TVV18d++67bwwfPjz22WefmDhxYlx77bVx3nnnVdV92GGHRUTEAw88EBs3bowLL7wwOnXqFC+99FLccsst8de//jUeeOCBiIg4//zz429/+1vMmTMn/t//+381ztFrr70W3/rWt6Jdu3bxwx/+MFq0aBG33357DB06NObNmxeHHHJIyvhLLrkkOnToENddd12sWrUqpkyZEhdffHHcd999EfHlVdzHHntsdO7cOcaNGxft27ePVatWxYMPPlhjLRMmTIiioqIYNmxYXHjhhbFs2bKYOnVqLFiwIP73f/83WrRoEVOmTIlf//rXMXv27Jg6dWrsvPPOsd9++9V47k0WLFgQxx13XAwaNCgefvjhtK7qfe655+LBBx+Miy66KNq2bRs333xzfPe7343Vq1dHp06dIiLivffei3/6p3+qaqJ37tw5Hn/88fjBD34QZWVlMXbs2IiIKCsri//+7/+OM844I84999z4+OOP46677orjjjsuXnrppWq3zJ82bVp8+umncd5550VeXl507NgxXnvttRgyZEh84xvfiHHjxkWbNm3i/vvvj5EjR8Zvf/vbqscBTJgwIYqLi+Occ86Jgw8+OMrKymLhwoWxePHiOOaYY7b5s7LJihUr4l/+5V/iBz/4QYwePTp+9atfxZgxY+Kggw6KAQMGRETExo0b48gjj4x33nknzj///OjVq1c8//zzUVhYGO+++25MmTJli+d/+eWX4/jjj4/u3btHUVFRVFRUxMSJE6v9w5ZteX82Oe2006J3795RXFwc8+fPj5tvvjk++uijlKb+OeecEzNmzIh/+Zd/iX//93+PF198MYqLi+ONN96I2bNnp5xv2bJlccYZZ8T5558f5557buy9997x2muvxQknnBD77bdfTJw4MfLy8mLFihXxv//7v2nPMQAAQJOXAAAAUKN169YlEZGcfPLJWx130kknJRGRlJWVJUmSJNddd10SEckZZ5xRbeymfZusWrUqyc3NTW644YaUca+++mrSvHnzlO1HHnlkEhHJr3/966pt5eXlSbdu3ZLvfve7VdsWLFiQREQybdq0aq+/cePGatuKi4uTnJyc5O23367aVlBQkGzp62NEJNddd13V+siRI5OWLVsmf/nLX6q2/e1vf0vatm2bHHHEEVXbpk2blkREMmzYsKSysrJq++WXX57k5uYm69atS5IkSWbPnp1ERLJgwYLNvv6WrF27NmnZsmVy7LHHJhUVFVXbf/GLXyQRkfzqV7+q2rbpfXj//fdrPO/o0aOTNm3aJEmSJM8991zSrl27ZMSIEcmnn36aVl0RkbRs2TJZsWJF1bZXXnkliYjklltuqdr2gx/8IOnevXvywQcfpBx/+umnJ/n5+VXv3RdffJGUl5enjPnoo4+Srl27JmeffXbVtpUrVyYRkbRr1y5Zu3Ztyvijjz462XfffVN+hsrKyuSwww5L9txzz6pt+++/fzJixIit/nxb+6xszm677ZZERPLss89WbVu7dm2Sl5eX/Pu//3vVtuuvvz5p06ZN8uabb6YcP27cuCQ3NzdZvXp11bavfyZPPPHEZKeddkreeeedqm3Lly9PmjdvXq3WdN+fTZ+Zk046KeX4iy66KImI5JVXXkmSJEmWLFmSRERyzjnnpIy78sork4hI/vCHP1SbiyeeeCJl7H/913+l/fkEAABg89w+HQAAIA0ff/xxRES0bdt2q+M27S8rK0vZfsEFF9T4Gg8++GBUVlbGaaedFh988EHV0q1bt9hzzz2r3RJ75513jjPPPLNqvWXLlnHwwQfHW2+9ldbP9NWrmjds2BAffPBBHHbYYZEkSbz88stpneOrKioq4ve//32MHDky5Znq3bt3j+9973vx3HPPVZuX8847L+UW1t/61reioqIi3n777Yj4x3O+H3300fj888/TruWpp56Kzz77LMaOHRvNmv3jq++5554b7dq12+zV/NvimWeeieOOOy6OPvroePDBByMvLy/tY4cNGxZ9+/atWt9vv/2iXbt2Ve9bkiTx29/+Nk488cRIkiTls3DcccdFaWlpLF68OCIicnNzq54JXllZGR9++GF88cUXMWjQoKoxX/Xd73435QrpDz/8MP7whz/EaaedFh9//HHV6/z973+P4447LpYvXx7vvPNORHz5Xrz22muxfPnybZ+wrejfv3/K3Rc6d+4ce++9d8rn+IEHHohvfetb0aFDh5T5GDZsWFRUVMSzzz672XNXVFTEU089FSNHjowePXpUbd9jjz1i+PDhmz2mpvfnqwoKClLWL7nkkoiI+N3vfpfyv1dccUXKuH//93+PiKj2OezTp08cd9xxKds2/Q48/PDD1W4VDwAAQHo0xQEAANKwqdm9qTm+JVtqnvfp06fG11i+fHkkSRJ77rlndO7cOWV54403Yu3atSnjd91112rPRO7QoUN89NFHNb5WRMTq1atjzJgx0bFjx9h5552jc+fOceSRR0ZERGlpaVrn+Kr3338/Nm7cGHvvvXe1ffvss09UVlZWezZ6r169qtUfEVU/w5FHHhnf/e53o6ioKHbZZZc4+eSTY9q0adWex/x1m5rqX6+lZcuWsfvuu1ft3x6ffvppjBgxIg444IC4//77q5rSm5SWlsaaNWuqlg8//DBl/9d/5ojU9+3999+PdevWVT27/qvLWWedFRGR8lmYMWNG7LffflXP+e7cuXM89thjm30Pv/45XLFiRSRJEuPHj6/2Wpuetb7ptSZOnBjr1q2LvfbaK/bdd9+46qqr4k9/+tO2Tl81Nc1HxJe/G0888US1GocNG1ZtPr5q7dq18cknn8Qee+xRbd/mtqVbzyZ77rlnynrfvn2jWbNmsWrVqoj48nPYrFmzaq/VrVu3aN++fbXP4eb+nPjXf/3XGDJkSJxzzjnRtWvXOP300+P+++/XIAcAANgGnikOAACQhvz8/OjevXuNTcA//elP8Y1vfCPatWuXsj2dZ01XVlZGTk5OPP7445Gbm1tt/84775yyvrkxEbHVZ5ZvUlFREcccc0x8+OGHcfXVV0e/fv2iTZs28c4778SYMWPqreFW08+Qk5MTv/nNb2L+/PnxP//zP/Hkk0/G2WefHf/5n/8Z8+fPrzYn9SEvLy/++Z//OR5++OF44okn4oQTTkjZf9lll8WMGTOq1o888siYO3du1XpNP/OmuT/zzDNj9OjRmx276bnnd999d4wZMyZGjhwZV111VXTp0iVyc3OjuLg4/vKXv1Q77uufw02vdeWVV1a7QnmTTQ3dI444Iv7yl7/Eww8/HL///e/jv//7v+O//uu/4rbbbotzzjlns8emI53PcWVlZRxzzDHxwx/+cLNj99prr+1+/e2pZ0u+/o9Uatr+dZv7c6J169bx7LPPxjPPPBOPPfZYPPHEE3HffffFt7/97fj973+/xXoBAAD4B01xAACANJ1wwglx5513xnPPPReHH354tf1//OMfY9WqVXH++edv1/n79u0bSZJEnz59aq3Jt6Vm3KuvvhpvvvlmzJgxI/7t3/6tavucOXPSPsfXde7cOXbaaadYtmxZtX1//vOfo1mzZtGzZ880K0/1T//0T/FP//RPccMNN8TMmTNj1KhRMWvWrC02Y3fbbbeIiFi2bFnKrdw/++yzWLlyZdUVxtsjJycn7rnnnjj55JPj1FNPjccffzyGDh1atf+HP/xhym3tN139nq7OnTtH27Zto6KiosY6f/Ob38Tuu+8eDz74YMr7tOkq75psmpsWLVqkNScdO3aMs846K84666xYv359HHHEETFhwoSq9yHdz8q26tu3b6xfv36b37cuXbpEq1atYsWKFdX2bW7btlq+fHnK1d0rVqyIysrK6N27d0R8+TmsrKyM5cuXxz777FM17r333ot169ZVfU5r0qxZszj66KPj6KOPjptuuikmTZoU//Ef/xHPPPPMDn2WAQAAmgq3TwcAAEjTVVddFa1bt47zzz8//v73v6fs+/DDD+OCCy6InXbaKa666qrtOv93vvOdyM3NjaKiompXpSZJUu0109GmTZuIiFi3bl3K9k1Xl371dZIkiZ///Odpn+PrcnNz49hjj42HH3646vbREV82AGfOnBmHH354tSvoa/LRRx9Vm4tvfvObERFbvYX6sGHDomXLlnHzzTenHH/XXXdFaWlpjBgxYpvq+LqWLVvGgw8+GIMHD44TTzwxXnrppap9/fv3j2HDhlUtBx100DadOzc3N7773e/Gb3/721i6dGm1/e+//37K2IjU9/HFF1+MF154Ia3X6tKlSwwdOjRuv/32ePfdd7f6Wl///O28886xxx57pLwP6X5WttVpp50WL7zwQjz55JPV9q1bty6++OKLzR6Xm5sbw4YNi4ceeij+9re/VW1fsWJFPP744ztc16233pqyfsstt0REVD2v/J//+Z8jImLKlCkp42666aaIiLQ+h1+//X5Eer8DAAAA/IMrxQEAANK05557xowZM2LUqFGx7777xg9+8IPo06dPrFq1Ku6666744IMP4t57742+fftu1/n79u0bP/7xj6OwsDBWrVoVI0eOjLZt28bKlStj9uzZcd5558WVV165zeds37593HbbbdG2bdto06ZNHHLIIdGvX7/o27dvXHnllfHOO+9Eu3bt4re//e1mn5u8qal76aWXxnHHHRe5ublx+umnb/b1fvzjH8ecOXPi8MMPj4suuiiaN28et99+e5SXl8dPfvKTbZ6TGTNmxC9/+cs45ZRTom/fvvHxxx/HnXfeGe3atatqOG5O586do7CwMIqKiuL444+Pk046KZYtWxa//OUvY/DgwSlXcm+v1q1bx6OPPhrf/va3Y/jw4TFv3rwYOHDgDp83ImLy5MnxzDPPxCGHHBLnnntu9O/fPz788MNYvHhxPPXUU1WN0hNOOCEefPDBOOWUU2LEiBGxcuXKuO2226J///6xfv36tF7r1ltvjcMPPzz23XffOPfcc2P33XeP9957L1544YX461//Gq+88kpEfNnsHzp0aBx00EHRsWPHWLhwYfzmN7+Jiy++uOpc2/JZ2RZXXXVVPPLII3HCCSfEmDFj4qCDDooNGzbEq6++Gr/5zW9i1apVscsuu2z22AkTJsTvf//7GDJkSFx44YVRUVERv/jFL2LgwIGxZMmSHapr5cqVcdJJJ8Xxxx8fL7zwQtx9993xve99L/bff/+IiNh///1j9OjRcccdd8S6deviyCOPjJdeeilmzJgRI0eOjKOOOqrG15g4cWI8++yzMWLEiNhtt91i7dq18ctf/jJ23XXXzd6xAgAAgOo0xQEAALbBqaeeGv369Yvi4uKqRninTp3iqKOOimuuuWaHm6Ljxo2LvfbaK/7rv/4rioqKIiKiZ8+eceyxx8ZJJ520zedr0aJFzJgxIwoLC+OCCy6IL774IqZNmxZjxoyJ//mf/4lLL700iouLo1WrVnHKKafExRdfXNXQ2+Q73/lOXHLJJTFr1qy4++67I0mSLTY6BwwYEH/84x+jsLAwiouLo7KyMg455JC4++6745BDDtnm+jc1EWfNmhXvvfde5Ofnx8EHHxz33HNPym2rN2fChAnRuXPn+MUvfhGXX355dOzYMc4777yYNGlStGjRYptr2Zx27drFk08+GUcccUQcc8wx8cc//rHqGdw7omvXrvHSSy/FxIkT48EHH4xf/vKX0alTpxgwYEDceOONVePGjBkTa9asidtvvz2efPLJ6N+/f9x9993xwAMPpDzHfGv69+8fCxcujKKiopg+fXr8/e9/jy5dusQBBxwQ1157bdW4Sy+9NB555JH4/e9/H+Xl5bHbbrvFj3/845Q7I2zLZ2Vb7LTTTjFv3ryYNGlSPPDAA/HrX/862rVrF3vttVcUFRVFfn7+Fo896KCD4vHHH48rr7wyxo8fHz179oyJEyfGG2+8EX/+8593qK777rsvrr322hg3blw0b948Lr744vjpT3+aMua///u/Y/fdd4/p06fH7Nmzo1u3blFYWJj2Le5POumkWLVqVfzqV7+KDz74IHbZZZc48sgja/y5AQAA+Iec5Ov3oQMAAADIciNHjozXXnstli9fnulSAAAAqGOeKQ4AAABktU8++SRlffny5fG73/0uhg4dmpmCAAAAqFeuFAcAAACyWvfu3WPMmDGx++67x9tvvx1Tp06N8vLyePnll2PPPffMdHkAAADUMc8UBwAAALLa8ccfH/fee2+sWbMm8vLy4tBDD41JkyZpiAMAADQRrhQHAAAAAAAAIGt5pjgAAAAAAAAAWUtTHAAAAAAAAICspSkOAAAAAAAAQNbSFAcAAAAAAAAga2mKAwAAAAAAAJC1NMUBAAAAAAAAyFqa4gAAAAAAAABkLU1xAAAAAAAAALKWpjgAAAAAAAAAWUtTHAAAAAAAAICspSkOAAAAAAAAQNbSFAcAAAAAAAAga2mKAwAAAAAAAJC1NMUBAAAAAAAAyFqa4gAAAAAAAABkLU1xAAAAAAAAALKWpjgAAAAAAAAAWUtTHAAAAAAAAICs1TzTBdS1ysrK+Nvf/hZt27aNnJycTJcD0CgkSRIff/xx9OjRI5o1azr/fkpmAGw7mSEzANIlM2QGQLpkhswASFe6mZH1TfG//e1v0bNnz0yXAdAolZSUxK677prpMuqNzADYfjIDgHTJDADSJTMASFdNmZH1TfG2bdtGxJcT0a5duwxXA9A4lJWVRc+ePav+DG0qZAbAtpMZMgMgXTJDZgCkS2bIDIB0pZsZWd8U33SLkXbt2gkRgG3U1G7TJDMAtp/MACBdMgOAdMkMANJVU2Y0nYdxAAAAAAAAANDkaIoDAAAAAAAAkLU0xQEAAAAAAADIWln/THEAAAAAAACA+lZZWRmfffZZpsto1Fq0aBG5ubk7fB5NcQAAAAAAAIBa9Nlnn8XKlSujsrIy06U0eu3bt49u3bpFTk7Odp9DUxwAAAAAAACgliRJEu+++27k5uZGz549o1kzT7TeHkmSxMaNG2Pt2rUREdG9e/ftPpemOAAAAAAAAEAt+eKLL2Ljxo3Ro0eP2GmnnTJdTqPWunXriIhYu3ZtdOnSZbtvpe6fJQAAAAAAAADUkoqKioiIaNmyZYYryQ6b/mHB559/vt3n0BQHAAAAAAAAqGU78gxs/qE25lFTHAAAAAAAAICspSkOAAAAAAAAQDz77LNx4oknRo8ePSInJyceeuihGo/p3bt35OTkxPz581O2jx07NoYOHVq1PmHChMjJyYkLLrggZdySJUsiJycnVq1aVQs/weY1r7Mzp+HZZ5+Nn/70p7Fo0aJ49913Y/bs2TFy5MiI+PKe8D/60Y/id7/7Xbz11luRn58fw4YNi8mTJ0ePHj0yWTYADVBFRUVMmDAh7r777lizZk306NEjxowZEz/60Y/cogYAANhuvXv3jrfffrva9osuuihuvfXWDFQEQCZsrZ8REZEkSVx33XVx5513xrp162LIkCExderU2HPPPTNXNNDg9B73WL2+3qrJI7b5mA0bNsT+++8fZ599dnznO99J+7hWrVrF1VdfHfPmzatx3F133RX//u//Xq9/Rmb0SvFNk7q5LxAbN26MxYsXx/jx42Px4sXx4IMPxrJly+Kkk07KQKUANHQ33nhjTJ06NX7xi1/EG2+8ETfeeGP85Cc/iVtuuSXTpQEAAI3YggUL4t13361a5syZExERp556aoYrA6A+ba2fERHxk5/8JG6++ea47bbb4sUXX4w2bdrEcccdF59++mk9VwqwY4YPHx4//vGP45RTTtmm484777yYP39+/O53v9vquL333juOOuqo+I//+I8dKXObZfRK8eHDh8fw4cM3uy8/P7/qS8Ymv/jFL+Lggw+O1atXR69eveqjRAAaieeffz5OPvnkGDHiy3/51rt377j33nvjpZdeynBlAABAY9a5c+eU9cmTJ0ffvn3jyCOPzFBFAGTC1voZSZLElClT4kc/+lGcfPLJERHx61//Orp27RoPPfRQnH766fVZKkBG9OnTJy644IIoLCyM448/Ppo12/K12ZMnT47BgwfHwoULY9CgQfVSX6N6pnhpaWnk5ORE+/btM10KAA3MYYcdFk8//XS8+eabERHxyiuvxHPPPbfFLysAAADb6rPPPou77747zj77bI9pAqDKypUrY82aNTFs2LCqbfn5+XHIIYfECy+8kMHKAOrXj370o1i5cmXcc889Wx134IEHxmmnnRZXX311PVWW4SvFt8Wnn34aV199dZxxxhnRrl27LY4rLy+P8vLyqvWysrL6KA+ADBs3blyUlZVFv379Ijc3NyoqKuKGG26IUaNGbfEYmQEAAGyLhx56KNatWxdjxozZ4hjfMwCanjVr1kRERNeuXVO2d+3atWrf5sgMoLGZNGlSTJo0qWr99ddfT7m7d+fOnePKK6+Ma6+9Nv71X/91q+f68Y9/HPvss0/8/ve/jy5dutRZzZs0iqb4559/HqeddlokSRJTp07d6tji4uIoKiqqp8ogS0zIr2F/af3UATvg/vvvj3vuuSdmzpwZAwYMiCVLlsTYsWOjR48eMXr06M0eIzMyqKY/dyL82QMAjZ3vGWShu+66K4YPHx49evTY4hjfM6hrvcc9ttX9qyaPqKdKgB2VVZnhv/VAk3DBBRfEaaedVrW+ub8XX3HFFfHLX/4yfvnLX271XH379o1zzz03xo0bF3fddVet1/p1Df726Zsa4m+//XbMmTNnq1eJR0QUFhZGaWlp1VJSUlJPlQKQSVdddVWMGzcuTj/99Nh3333j+9//flx++eVRXFy8xWNkBgAAkK633347nnrqqTjnnHO2Os73DICmp1u3bhER8d5776Vsf++996r2bY7MABqbjh07xh577FG1NG9e/frrnXfeOcaPHx833HBDfPzxx1s937XXXhtvvvlmzJo1q65KrtKgm+KbGuLLly+Pp556Kjp16lTjMXl5edGuXbuUBYDst3HjxmjWLDXWcnNzo7KycovHyAwAACBd06ZNiy5dusSIEVu/Ctf3DICmp0+fPtGtW7d4+umnq7aVlZXFiy++GIceeugWj5MZQEO0fv36WLJkSSxZsiQiIlauXBlLliyJ1atXp32O8847L/Lz82PmzJlbHde1a9e44oor4uabb96RktOS0dunr1+/PlasWFG1vmlSO3bsGN27d49/+Zd/icWLF8ejjz4aFRUVVc/e6NixY7Rs2TJTZQPQAJ144olxww03RK9evWLAgAHx8ssvx0033RRnn312pksDAAAaucrKypg2bVqMHj16s1fDAJD9ttbP6NWrV4wdOzZ+/OMfx5577hl9+vSJ8ePHR48ePWLkyJGZKxpgOyxcuDCOOuqoqvUrrrgiIiJGjx4d06dPT+scLVq0iOuvvz6+973v1Tj2yiuvjKlTp8ann366XfWmK6N/i9/apE6YMCEeeeSRiIj45je/mXLcM888E0OHDq2vMgFoBG655ZYYP358XHTRRbF27dro0aNHnH/++XHttddmujQAAKCRe+qpp2L16tX+0S1AE1ZTk+iHP/xhbNiwIc4777xYt25dHH744fHEE09Eq1atMlUy0ACtmrz1uw41BEOHDo0kSbbpmFWrVlXbdsYZZ8QZZ5yRsm3ChAkxYcKElG3t2rWL999/f1vL3GYZbYrXNKnbOuEANF1t27aNKVOmxJQpUzJdCgAAkGWOPfZY/50KoImrqZ+Rk5MTEydOjIkTJ9ZjVQCkq0E/UxwAAAAAAAAAdoSmOAAAAAAAAABZS1McAAAAAAAAgKylKQ4AAAAAAABA1tIUBwAAAAAAACBraYoDAAAAAAAAkLU0xQEAAAAAAADIWpriAAAAAAAAAGQtTXEAAAAAAAAAspamOAAAAAAAAEATV1xcHIMHD462bdtGly5dYuTIkbFs2bIaj8vJyYlWrVrF22+/nbJ95MiRMWbMmKr1MWPGRE5OTkyePDll3EMPPRQ5OTm18jNsSfM6PTsAAAAAAAAAERPy6/n1Srdp+Lx586KgoCAGDx4cX3zxRVxzzTVx7LHHxuuvvx5t2rTZ6rE5OTlx7bXXxowZM7Y6rlWrVnHjjTfG+eefHx06dNim+naEK8UBAAAAAAAAmrgnnngixowZEwMGDIj9998/pk+fHqtXr45FixbVeOzFF18cd999dyxdunSr44YNGxbdunWL4uLi2io7LZriAAAAAAAAAKQoLf3ySvOOHTvWOHbIkCFxwgknxLhx47Y6Ljc3NyZNmhS33HJL/PWvf62VOtOhKQ4AAAAAAABAlcrKyhg7dmwMGTIkBg4cmNYxxcXF8cQTT8Qf//jHrY475ZRT4pvf/GZcd911tVFqWjTFAQAAAAAAAKhSUFAQS5cujVmzZlVtu+CCC2LnnXeuWr6uf//+8W//9m81Xi0eEXHjjTfGjBkz4o033qjVurdEUxwAAAAAAACAiPjy+eCPPvpoPPPMM7HrrrtWbZ84cWIsWbKkatmcoqKiWLx4cTz00ENbfY0jjjgijjvuuCgsLKzFyreseb28CgAAAAAAAAANVpIkcckll8Ts2bNj7ty50adPn5T9Xbp0iS5dumz1HD179oyLL744rrnmmujbt+9Wx06ePDm++c1vxt57773DtdfEleIAAAAAAAAATVxBQUHcfffdMXPmzGjbtm2sWbMm1qxZE5988sk2naewsDD+9re/xVNPPbXVcfvuu2+MGjUqbr755h0pOy2a4gAAAAAAAABN3NSpU6O0tDSGDh0a3bt3r1ruu+++bTpPx44d4+qrr45PP/20xrETJ06MysrK7S05bW6fDgAAAAAAAFDXJpRmuoKtSpKk1o4rLCys9rzw6dOnVxvXu3fvKC8v367X3RauFAcAAAAAAAAga2mKAwAAAAAAAJC1NMUBAAAAAAAAyFqa4gAAAAAAAABkLU1xAAAAAAAAALJW80wXQOPQe9xjNY5ZNXlEPVQCAAAAAAAADV+SJJkuISvUxjy6UhwAAAAAAACgluTm5kZExGeffZbhSrLDxo0bIyKiRYsW230OV4oDAAAAAAAA1JLmzZvHTjvtFO+//360aNEimjVznfL2SJIkNm7cGGvXro327dtX/WOD7aEpDgAAAAAAAFBLcnJyonv37rFy5cp4++23M11Oo9e+ffvo1q3bDp1DUxwAAAAAAACgFrVs2TL23HNPt1DfQS1atNihK8Q30RQHAAAAAAAAqGXNmjWLVq1aZboMIsIN7AEAgCaloqIixo8fH3369InWrVtH37594/rrr48kSTJdGgAAAAB1wJXiAABAk3LjjTfG1KlTY8aMGTFgwIBYuHBhnHXWWZGfnx+XXnpppssDAAAAoJZpigMAAE3K888/HyeffHKMGDEiIiJ69+4d9957b7z00ksZrgwAAACAuuD26QAAQJNy2GGHxdNPPx1vvvlmRES88sor8dxzz8Xw4cO3eEx5eXmUlZWlLAAAAAA0Dq4UBwAAmpRx48ZFWVlZ9OvXL3Jzc6OioiJuuOGGGDVq1BaPKS4ujqKionqsEgAAAIDa4kpxALJG7969Iycnp9pSUFCQ6dIAaEDuv//+uOeee2LmzJmxePHimDFjRvzsZz+LGTNmbPGYwsLCKC0trVpKSkrqsWIAAAAAdoQrxQHIGgsWLIiKioqq9aVLl8YxxxwTp556agarAqChueqqq2LcuHFx+umnR0TEvvvuG2+//XYUFxfH6NGjN3tMXl5e5OXl1WeZAAAAANQSTXEAskbnzp1T1idPnhx9+/aNI488MkMVAdAQbdy4MZo1S71pVm5ublRWVmaoIgAAAADqkqY4AFnps88+i7vvvjuuuOKKyMnJyXQ5ADQgJ554Ytxwww3Rq1evGDBgQLz88stx0003xdlnn53p0gAAAACoA5riAGSlhx56KNatWxdjxozZ4pjy8vIoLy+vWi8rK6uHygDItFtuuSXGjx8fF110UaxduzZ69OgR559/flx77bWZLg0AAACAOqApDkBWuuuuu2L48OHRo0ePLY4pLi6OoqKieqwKqE29xz1W45hVk0fUQyU0Nm3bto0pU6bElClTMl0KAAAAAPWgWc1DAKBxefvtt+Opp56Kc845Z6vjCgsLo7S0tGopKSmppwoBAAAAAID6oikOQNaZNm1adOnSJUaM2PoVonl5edGuXbuUBQAA4KveeeedOPPMM6NTp07RunXr2HfffWPhwoWZLgsAANgGbp8OQFaprKyMadOmxejRo6N5czEHAABsv48++iiGDBkSRx11VDz++OPRuXPnWL58eXTo0CHTpQEAANtAtwCArPLUU0/F6tWr4+yzz850KQAAQCN34403Rs+ePWPatGlV2/r06ZPBigAAgO3h9ukAZJVjjz02kiSJvfbaK9OlAAAAjdwjjzwSgwYNilNPPTW6dOkSBxxwQNx5551bPaa8vDzKyspSFgAAILM0xQEAAABgM956662YOnVq7LnnnvHkk0/GhRdeGJdeemnMmDFji8cUFxdHfn5+1dKzZ896rBgAANgcTXEAAAAA2IzKyso48MADY9KkSXHAAQfEeeedF+eee27cdtttWzymsLAwSktLq5aSkpJ6rBgAANgcTXEAAAAA2Izu3btH//79U7bts88+sXr16i0ek5eXF+3atUtZAACAzNIUBwAAAIDNGDJkSCxbtixl25tvvhm77bZbhioCAAC2h6Y4AAAAAGzG5ZdfHvPnz49JkybFihUrYubMmXHHHXdEQUFBpksDAAC2QUab4s8++2yceOKJ0aNHj8jJyYmHHnooZX+SJHHttddG9+7do3Xr1jFs2LBYvnx5ZooFAAAAoEkZPHhwzJ49O+69994YOHBgXH/99TFlypQYNWpUpksDAAC2QUab4hs2bIj9998/br311s3u/8lPfhI333xz3HbbbfHiiy9GmzZt4rjjjotPP/20nisFAAAAoCk64YQT4tVXX41PP/003njjjTj33HMzXRIAALCNmmfyxYcPHx7Dhw/f7L4kSWLKlCnxox/9KE4++eSIiPj1r38dXbt2jYceeihOP/30+iwVAAAAAAAAgEaowT5TfOXKlbFmzZoYNmxY1bb8/Pw45JBD4oUXXtjiceXl5VFWVpayAAAAAAAAANA0ZfRK8a1Zs2ZNRER07do1ZXvXrl2r9m1OcXFxFBUV1WltAHWt97jHahyzqtX3aj7RhNJaqAYAAAAAAKDxarBXim+vwsLCKC0trVpKSkoyXRIAAAAAAAAAGdJgm+LdunWLiIj33nsvZft7771XtW9z8vLyol27dikLAAAAAAAAAE1Tg22K9+nTJ7p16xZPP/101baysrJ48cUX49BDD81gZQAAAAAAAAA0Fhl9pvj69etjxYoVVesrV66MJUuWRMeOHaNXr14xduzY+PGPfxx77rln9OnTJ8aPHx89evSIkSNH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6TIAAAAAAIAGxO3TAcgay5cvjx49esTuu+8eo0aNitWrV291fHl5eZSVlaUsAAAAAABAdnGlOABZ4ZBDDonp06fH3nvvHe+++24UFRXFt771rVi6dGm0bdt2s8cUFxdHUVFR/RY6IT+NMaV1Xwc0NDX9bvi9AAAAAAC2kyvFAcgKw4cPj1NPPTX222+/OO644+J3v/tdrFu3Lu6///4tHlNYWBilpaVVS0lJST1WDAAAAAAA1AdXigOQldq3bx977bVXrFixYotj8vLyIi8vrx6rAgAAAAAA6psrxQHISuvXr4+//OUv0b1790yXAgAAAAAAZJCmOABZ4corr4x58+bFqlWr4vnnn49TTjklcnNz44wzzsh0aQAAAAAAQAZpigOQFf7617/GGWecEXvvvXecdtpp0alTp5g/f3507tw506UBAAAA0AS88847ceaZZ0anTp2idevWse+++8bChQszXRYA4ZniAGSJWbNmZboEAAAAAJqojz76KIYMGRJHHXVUPP7449G5c+dYvnx5dOjQIdOlARCa4gAAAAAAADvkxhtvjJ49e8a0adOqtvXp0yeDFQHwVW6fDgAAAAAAsAMeeeSRGDRoUJx66qnRpUuXOOCAA+LOO+/MdFkA/B9NcQAAAAAAgB3w1ltvxdSpU2PPPfeMJ598Mi688MK49NJLY8aMGVs8pry8PMrKylIWAOqG26cDAAAAAADsgMrKyhg0aFBMmjQpIiIOOOCAWLp0adx2220xevTozR5TXFwcRUVF9VkmQJPlSnEAAAAAAIAd0L179+jfv3/Ktn322SdWr169xWMKCwujtLS0aikpKanrMgGaLFeKAwAAAAAA7IAhQ4bEsmXLUra9+eabsdtuu23xmLy8vMjLy6vr0gAIV4oDAAAAAADskMsvvzzmz58fkyZNihUrVsTMmTPjjjvuiIKCgkyXBkBoigMAAAAAAOyQwYMHx+zZs+Pee++NgQMHxvXXXx9TpkyJUaNGZbo0AMLt0wEAAAAAAHbYCSecECeccEKmywBgMzTFm6IJ+WmMKa37OtLV2OoFAAAAstLkyZOjsLAwLrvsspgyZUqmywEAANLk9ukAAAAAUIMFCxbE7bffHvvtt1+mSwEAALaRpjgAAAAAbMX69etj1KhRceedd0aHDh0yXQ4AALCNtqspvvvuu8ff//73atvXrVsXu++++w4XBUDTIlcASJfMACAdtZ0XBQUFMWLEiBg2bFiNY8vLy6OsrCxlASBzfIcAIGI7nym+atWqqKioqLa9vLw83nnnnR0uCoCmRa4AkC6ZAUA6ajMvZs2aFYsXL44FCxakNb64uDiKioq26TXInN7jHqtxzKpW36v5RBNKt/3FJ+TXzXmbiLTeu8kj6qESGjrfIQCI2Mam+COPPFL1/5988snIz//HX9wqKiri6aefjt69e9dacQBkN7kCQLpkBgDpqO28KCkpicsuuyzmzJkTrVq1SuuYwsLCuOKKK6rWy8rKomfPnmm/JgC1w3cIAL5qm5riI0eOjIiInJycGD16dMq+Fi1aRO/eveM///M/a604ALKbXAEgXTIDgHTUdl4sWrQo1q5dGwceeGDVtoqKinj22WfjF7/4RZSXl0dubm7KMXl5eZGXl7f9PwQAtcJ3CAC+apua4pWVlRER0adPn1iwYEHssssudVIUAE2DXAEgXTIDgHTUdl4cffTR8eqrr6ZsO+uss6Jfv35x9dVXV2uIA9Bw+A4BwFdt1zPFV65cWdt1ANCEyRUA0iUzAEhHbeVF27ZtY+DAgSnb2rRpE506daq2HYCGyXcIACK2sykeEfH000/H008/HWvXrq36F1eb/OpXv9rhwgBoWuQKAOmSGQCkQ14AsIlMAGC7muJFRUUxceLEGDRoUHTv3j1ycnJquy4AmhC5AkC6mlRmTMhPY0xp3ddRh3qPe6zGMata1UMhUJeawO9yQ1SXeTF37txaOxcAda9JfYcAYIu2qyl+2223xfTp0+P73/9+bdcDQBMkVwBIl8wAIB3yAoBNZAIAERHNtuegzz77LA477LDargWAJkquAJAumQFAOuQFAJvIBAAitrMpfs4558TMmTNruxYAmii5AkC6ZAYA6ZAXAGwiEwCI2M7bp3/66adxxx13xFNPPRX77bdftGjRImX/TTfdVCvFAdA0yBUA0iUzAEiHvABgE5kAQMR2NsX/9Kc/xTe/+c2IiFi6dGnKvpycnB0uCoCmRa4AkC6ZAUA65AUAm8gEACK2syn+zDPP1HYdADRhcgWAdMkMANIhLwDYRCYAELGdzxQHAAAAAAAAgMZgu64UP+qoo7Z6W5E//OEP210QAE2PXAEgXTIDgHTICwA2kQkARGxnU3zT8zc2+fzzz2PJkiWxdOnSGD16dG3UBUATIlcASJfMACAd8gKATWQCABHb2RT/r//6r81unzBhQqxfv36HCgKg6ZErAKRLZgCQDnkBwCYyAYCIWn6m+Jlnnhm/+tWvavOUADRhcgWAdMkMANIhLwDYRCYANC212hR/4YUXolWrVrV5SgCaMLkCQLpkBgDpkBcAbCITAJqW7bp9+ne+852U9SRJ4t13342FCxfG+PHja6UwAJoOuQJAumQGAOmQFwBsIhMAiNjOpnh+fn7KerNmzWLvvfeOiRMnxrHHHlsrhQHQdMgVANIlMwBIh7wAYBOZAEDEdjbFp02bVtt1ANCEyRUA0iUzAEiHvABgE5kAQMR2NsU3WbRoUbzxxhsRETFgwIA44IADaqUoAJomuQJAumQGAOmQFwBsIhMAmrbtaoqvXbs2Tj/99Jg7d260b98+IiLWrVsXRx11VMyaNSs6d+5cawW+8847cfXVV8fjjz8eGzdujD322COmTZsWgwYNqrXXACCz6iJXJk+eHIWFhXHZZZfFlClTardgADKmPr+LANB4yQsANpEJAERENNuegy655JL4+OOP47XXXosPP/wwPvzww1i6dGmUlZXFpZdeWmvFffTRRzFkyJBo0aJFPP744/H666/Hf/7nf0aHDh1q7TUAyLzazpUFCxbE7bffHvvtt18dVAtAJtXXdxEAGjd5AcAmMgGAiO28UvyJJ56Ip556KvbZZ5+qbf37949bb701jj322For7sYbb4yePXumPPOjT58+tXZ+ABqG2syV9evXx6hRo+LOO++MH//4x7VdKgAZVl/fRQBo3OQFAJvIBAAitvNK8crKymjRokW17S1atIjKysodLmqTRx55JAYNGhSnnnpqdOnSJQ444IC48847t3pMeXl5lJWVpSwANGy1mSsFBQUxYsSIGDZsWI1jZQZA41Nf30UAaNzkBQCbyAQAIrbzSvFvf/vbcdlll8W9994bPXr0iIgvn/19+eWXx9FHH11rxb311lsxderUuOKKK+Kaa66JBQsWxKWXXhotW7aM0aNHb/aY4uLiKCoqqrUaqFu9xz1W45hVreqhkCxmjutWevP7vZpPNKG0FqppvGorV2bNmhWLFy+OBQsWpDVeZtSNuvpzJ63zTh6x7ScGGpX6+i4CQOMmLwDYRCbsOP+NGcgG23Wl+C9+8YsoKyuL3r17R9++faNv377Rp0+fKCsri1tuuaXWiqusrIwDDzwwJk2aFAcccECcd955ce6558Ztt922xWMKCwujtLS0aikpKam1egCoG7WRKyUlJXHZZZfFPffcE61apfe3cJkB0PjU13cRABo3eQHAJjIBgIjtvFK8Z8+esXjx4njqqafiz3/+c0RE7LPPPmndqnZbdO/ePfr375+ybZ999onf/va3WzwmLy8v8vLyarUOAOpWbeTKokWLYu3atXHggQdWbauoqIhnn302fvGLX0R5eXnk5uamHCMzABqf+vouAkDjJi8A2EQmABCxjVeK/+EPf4j+/ftHWVlZ5OTkxDHHHBOXXHJJXHLJJTF48OAYMGBA/PGPf6y14oYMGRLLli1L2fbmm2/GbrvtVmuvAUDm1GauHH300fHqq6/GkiVLqpZBgwbFqFGjYsmSJdUa4gA0LvX9XQSAxkleALCJTADgq7apKT5lypQ499xzo127dtX25efnx/nnnx833XRTrRV3+eWXx/z582PSpEmxYsWKmDlzZtxxxx1RUFBQa68BQObUZq60bds2Bg4cmLK0adMmOnXqFAMHDqzt0gGoZ/X9XQSAxkleALCJTADgq7apKf7KK6/E8ccfv8X9xx57bCxatGiHi9pk8ODBMXv27Lj33ntj4MCBcf3118eUKVNi1KhRtfYaAGROfecKAI2XzAAgHfICgE1kAgBftU3PFH/vvfeiRYsWWz5Z8+bx/vvv73BRX3XCCSfECSecUKvnBKBhqOtcmTt37nYfC0DDkonvIgA0PvICgE1kAgBftU1Xin/jG9+IpUuXbnH/n/70p+jevfsOFwVA0yBXAEiXzAAgHfICgE1kAgBftU1N8X/+53+O8ePHx6efflpt3yeffBLXXXedq7oBSJtcASBdMgOAdMgLADaRCQB81TbdPv1HP/pRPPjgg7HXXnvFxRdfHHvvvXdERPz5z3+OW2+9NSoqKuI//uM/6qRQALKPXAEgXTIDgHTICwA2kQkAfNU2NcW7du0azz//fFx44YVRWFgYSZJEREROTk4cd9xxceutt0bXrl3rpFAAso9cASBdMgOAdMgLADaRCQB81TY1xSMidtttt/jd734XH330UaxYsSKSJIk999wzOnToUBf1AZDl5AoA6ZIZAKRDXgCwiUwAYJNtbopv0qFDhxg8eHBt1gJAEyZXAEiXzAAgHfICgE1kAgDNMl0A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Y5Kjjjqq6rxHHnlk0q5du2TChAnJm2++mcyYMSPJyclJfv/73ydJkiRr165NIiKZNm1a8u677yZr165NkiRJlixZktx2223Jq6++mrz55pvJj370o6RVq1bJ22+/XfXz7LrrrsnEiROTd999N3n33XeTJKkeIg8++GDSokWL5NZbb02WLVuW/Od//meSm5ub/OEPf6gaExHJrrvumsycOTNZvnx5cumllyY777xz8ve//32L83nSSSelhG6SJMn8+fOTZs2aJTfeeGOybNmy5Oc//3nSvn37lHqeffbZpF27dsn06dOTv/zlL8nvf//7pHfv3smECROSJEmSL774Itl7772TY445JlmyZEny/9u7/5iq6j+O4y8U0DsRBCm6FoMBBtRmQUUhrtYPZGtjarM1dROrsf5oaW205sbcUJtrI+wH2VZrMAqXf4Cb/uEl13Qyhs4/Ep3c8eOKSQU2s1pXE52++6N58HjlerSvX/T6fGxu3s8553M+91zg6Xau3M7OTispKblmRLKzs62trc2OHTtmv/zyi33zzTfm9/udsba2NktLS7Pm5mYzMzt//rwVFhbaa6+9ZocPH7be3l5bvny55efn29jYmF24cMFSUlKspqbGBgcHrbe315qbm51rbmZ25swZmzJliu3Zs2fCawPgzkczaAbNAOAVzaAZNAOAVzSDZtAMAF7RDJpBM+4O3BTHLbF//37XD6KrNTQ0mCQ7efKkmf0bkcWLF7v2uToiGzZssIULF7r2GR4eNknW19dnZv9GZMGCBa59nnjiCXvvvfecx9HWdaWHH37YPv30U+dxVlaWbd682bXP1RGZP3++VVdXu/Z5+eWX7cUXX3Sdv7a21nkcDodNku3atWvCtTzyyCO2fv1619iyZctc85qZvfLKK671PP/88xEh//rrr83v95uZ2a5duyw+Pt6JoplN+M6qjz76yDVPbm6ubd261TW2YcMGKy0tdc6Tn5/v/EPBzGxsbMx8Pp91dHTYb7/9ZpJs7969Ez5vM7PU1FQnTABiE80YRzNoBoDoaMY4mkEzAERHM8bRDJoBIDqaMY5m0IxYxmeK45YyM8/7Pv7441G39/T0aM+ePUpKSnL+FBQUSJJCoZCz37x581zH+f1+/frrr1HnDofDqqmpUWFhoWbNmqWkpCQFg0GdOHHC8/olKRgMqqyszDVWVlam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", 188 | "text/plain": [ 189 | "
" 190 | ] 191 | }, 192 | "metadata": {}, 193 | "output_type": "display_data" 194 | } 195 | ], 196 | "source": [ 197 | "plot_surface_occupancies(surface_occupancies)\n", 198 | "plot_nn_distances(nn_dists)\n", 199 | "plot_knn_orientations(knn_orientations)" 200 | ] 201 | }, 202 | { 203 | "cell_type": "code", 204 | "execution_count": null, 205 | "metadata": {}, 206 | "outputs": [], 207 | "source": [] 208 | } 209 | ], 210 | "metadata": { 211 | "kernelspec": { 212 | "display_name": "venv_surforama", 213 | "language": "python", 214 | "name": "python3" 215 | }, 216 | "language_info": { 217 | "codemirror_mode": { 218 | "name": "ipython", 219 | "version": 3 220 | }, 221 | "file_extension": ".py", 222 | "mimetype": "text/x-python", 223 | "name": "python", 224 | "nbconvert_exporter": "python", 225 | "pygments_lexer": "ipython3", 226 | "version": "3.11.0" 227 | } 228 | }, 229 | "nbformat": 4, 230 | "nbformat_minor": 2 231 | } 232 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: surforama 2 | site_url: https://github.com/cellcanvas/surforama 3 | site_author: Alister Burt and Kevin Yamauchi 4 | site_description: >- 5 | Documentation for surforama, a tool for using surfaces 6 | to explore volumetric data in napari. 7 | # Repository 8 | repo_name: cellcanvas/surforama 9 | repo_url: https://github.com/cellcanvas/surforama 10 | 11 | # Copyright 12 | copyright: Copyright © 2024 CellCanvas team 13 | 14 | watch: 15 | - src/surforama 16 | 17 | theme: 18 | icon: 19 | logo: material/home-circle 20 | name: material 21 | palette: 22 | # Palette toggle for light mode 23 | - scheme: default 24 | primary: indigo 25 | accent: indigo 26 | toggle: 27 | icon: material/toggle-switch 28 | name: Switch to dark mode 29 | 30 | # Palette toggle for dark mode 31 | - scheme: slate 32 | primary: blue grey 33 | accent: blue grey 34 | toggle: 35 | icon: material/toggle-switch-off-outline 36 | name: Switch to light mode 37 | 38 | features: 39 | #- navigation.instant 40 | #- navigation.tabs 41 | #- navigation.top 42 | #- navigation.tracking 43 | - search.highlight 44 | - search.suggest 45 | # - toc.follow 46 | # - content.code.annotate 47 | # - navigation.sections 48 | - content.tabs.link 49 | - content.code.copy 50 | 51 | markdown_extensions: 52 | - admonition 53 | - tables 54 | - pymdownx.details 55 | - pymdownx.superfences 56 | - pymdownx.tabbed: 57 | alternate_style: true 58 | - attr_list 59 | - pymdownx.emoji: 60 | emoji_index: !!python/name:material.extensions.emoji.twemoji 61 | emoji_generator: !!python/name:material.extensions.emoji.to_svg 62 | - md_in_html 63 | - pymdownx.arithmatex: 64 | generic: true 65 | 66 | 67 | extra: 68 | analytics: 69 | feedback: 70 | title: Was this page helpful? 71 | ratings: 72 | - icon: material/emoticon-happy-outline 73 | name: This page was helpful 74 | data: 1 75 | note: >- 76 | Thanks for your feedback! 77 | - icon: material/emoticon-sad-outline 78 | name: This page could be improved 79 | data: 0 80 | note: >- 81 | Thanks for your feedback! Help us improve this page by 82 | using our feedback form. 83 | 84 | nav: 85 | - 'Overview': index.md 86 | - 'Getting started': getting_started.md 87 | - 'Installation': installation.md 88 | - 'CZ cryoET Data Portal': surforama_for_data_portal.md 89 | - 'Contributing': contributing.md 90 | 91 | plugins: 92 | - search 93 | - mkdocstrings: 94 | handlers: 95 | python: 96 | import: 97 | - https://docs.python.org/3/objects.inv 98 | selection: 99 | docstring_style: numpy 100 | filters: [ "!^_" ] 101 | rendering: 102 | show_root_heading: true 103 | show_root_toc_entry: true 104 | show_root_full_path: true 105 | show_object_full_path: false 106 | # show_root_members_full_path: false 107 | # show_category_heading: false 108 | show_if_no_docstring: false 109 | # show_signature: true 110 | show_signature_annotations: true 111 | show_source: true 112 | # show_bases: true 113 | # group_by_category: true 114 | # heading_level: 2 115 | members_order: source # alphabetical/source 116 | 117 | ## experimental 118 | docstring_section_style: spacy # or table/list/spacy 119 | - mkdocs-video: 120 | is_video: True 121 | video_autoplay: True 122 | video_loop: True 123 | # - gallery: 124 | # conf_script: docs/gallery_conf.py 125 | # examples_dirs: [docs/examples] 126 | # gallery_dirs: [docs/generated/gallery] 127 | # filename_pattern: /*.py # which scripts will be executed for the docs 128 | # ignore_pattern: /__init__.py # ignore these example files completely 129 | # run_stale_examples: True 130 | 131 | extra_css: 132 | - stylesheets/extra.css 133 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=42.0.0", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | 6 | [tool.setuptools_scm] 7 | write_to = "src/surforama/_version.py" 8 | 9 | 10 | [tool.black] 11 | line-length = 79 12 | target-version = ['py38', 'py39', 'py310'] 13 | 14 | 15 | [tool.ruff] 16 | line-length = 79 17 | select = [ 18 | "E", "F", "W", #flake8 19 | "UP", # pyupgrade 20 | "I", # isort 21 | "BLE", # flake8-blind-exception 22 | "B", # flake8-bugbear 23 | "A", # flake8-builtins 24 | "C4", # flake8-comprehensions 25 | "ISC", # flake8-implicit-str-concat 26 | "G", # flake8-logging-format 27 | "PIE", # flake8-pie 28 | "SIM", # flake8-simplify 29 | ] 30 | ignore = [ 31 | "E501", # line too long. let black handle this 32 | "UP006", "UP007", # type annotation. As using magicgui require runtime type annotation then we disable this. 33 | "SIM117", # flake8-simplify - some of merged with statements are not looking great with black, reanble after drop python 3.9 34 | ] 35 | 36 | exclude = [ 37 | ".bzr", 38 | ".direnv", 39 | ".eggs", 40 | ".git", 41 | ".mypy_cache", 42 | ".pants.d", 43 | ".ruff_cache", 44 | ".svn", 45 | ".tox", 46 | ".venv", 47 | "__pypackages__", 48 | "_build", 49 | "buck-out", 50 | "build", 51 | "dist", 52 | "node_modules", 53 | "venv", 54 | "*vendored*", 55 | "*_vendor*", 56 | ] 57 | 58 | target-version = "py38" 59 | fix = true 60 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = surforama 3 | version = attr: surforama.__version__ 4 | description = a tool for using surfaces to explore volumetric data in napari 5 | long_description = file: README.md 6 | long_description_content_type = text/markdown 7 | url = https://github.com/cellcanvas/surforama 8 | author = Kyle Harrington 9 | author_email = surforama@kyleharrington.com 10 | license = MIT 11 | license_file = LICENSE 12 | license_files = LICENSE 13 | classifiers = 14 | Development Status :: 2 - Pre-Alpha 15 | Framework :: napari 16 | Intended Audience :: Developers 17 | License :: OSI Approved :: MIT License 18 | Operating System :: OS Independent 19 | Programming Language :: Python 20 | Programming Language :: Python :: 3 21 | Programming Language :: Python :: 3 :: Only 22 | Programming Language :: Python :: 3.8 23 | Programming Language :: Python :: 3.9 24 | Programming Language :: Python :: 3.10 25 | Topic :: Scientific/Engineering :: Image Processing 26 | project_urls = 27 | Bug Tracker = https://github.com/cellcanvas/surforama/issues 28 | Documentation = https://github.com/cellcanvas/surforama#README.md 29 | Source Code = https://github.com/cellcanvas/surforama 30 | User Support = https://github.com/cellcanvas/surforama/issues 31 | 32 | [options] 33 | packages = find: 34 | install_requires = 35 | magicgui 36 | mrcfile 37 | numpy 38 | pooch 39 | qtpy 40 | pyacvd 41 | pyvista 42 | rich 43 | scikit-image 44 | starfile 45 | trimesh 46 | typer 47 | python_requires = >=3.8 48 | include_package_data = True 49 | package_dir = 50 | =src 51 | setup_requires = setuptools_scm 52 | 53 | [options.packages.find] 54 | where = src 55 | 56 | [options.entry_points] 57 | console_scripts = 58 | surforama = surforama._cli:app 59 | napari.manifest = 60 | surforama = surforama:napari.yaml 61 | 62 | [options.extras_require] 63 | dev = 64 | %(testing)s 65 | %(docs)s 66 | pre-commit 67 | 68 | docs = 69 | mkdocs 70 | mkdocs-material 71 | mkdocstrings[python] 72 | mkdocs-video 73 | 74 | testing = 75 | napari 76 | pyqt5 77 | pytest 78 | pytest-cov 79 | pytest-qt 80 | tox 81 | napari = 82 | napari[all] 83 | 84 | [options.package_data] 85 | * = *.yaml 86 | -------------------------------------------------------------------------------- /src/surforama/__init__.py: -------------------------------------------------------------------------------- 1 | from .app import QtSurforama 2 | 3 | __all__ = ("QtSurforama",) 4 | -------------------------------------------------------------------------------- /src/surforama/_cli.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | import typer 4 | from typing_extensions import Annotated 5 | 6 | app = typer.Typer( 7 | help="Surforama: view tomogram densities on a surface.", 8 | no_args_is_help=True, 9 | ) 10 | 11 | 12 | @app.command() 13 | def launch_surforama( 14 | image_path: Annotated[ 15 | Optional[str], typer.Option(help="Path to the image to load.") 16 | ] = None, 17 | mesh_path: Annotated[ 18 | Optional[str], typer.Option(help="Path to the mesh to load.") 19 | ] = None, 20 | demo: Annotated[ 21 | bool, typer.Option("--demo", help="launch surforama with sample data") 22 | ] = False, 23 | ): 24 | if demo and (image_path is not None or mesh_path is not None): 25 | raise ValueError( 26 | "Please do not specify an image/mesh path when launching in demo mode." 27 | ) 28 | 29 | # delay imports 30 | import mrcfile 31 | import napari 32 | 33 | from surforama.app import QtSurforama 34 | from surforama.io.mesh import read_obj_file 35 | 36 | viewer = napari.Viewer(ndisplay=3) 37 | 38 | if demo: 39 | # set up the viewer in demo mode 40 | from surforama.data._datasets import thylakoid_membrane 41 | 42 | # fetch the data 43 | tomogram, mesh_data = thylakoid_membrane() 44 | 45 | # Add the data to the viewer 46 | volume_layer = viewer.add_image( 47 | tomogram, blending="translucent", depiction="plane" 48 | ) 49 | surface_layer = viewer.add_surface(mesh_data) 50 | 51 | # set up the slicing plane position 52 | volume_layer.plane = {"normal": [1, 0, 0], "position": [66, 187, 195]} 53 | 54 | # set up the camera 55 | viewer.camera.center = (64.0, 124.0, 127.5) 56 | viewer.camera.zoom = 3.87 57 | viewer.camera.angles = ( 58 | -5.401480002668876, 59 | -0.16832643131442776, 60 | 160.28901483338126, 61 | ) 62 | 63 | else: 64 | # set up the viewer with the user-requested images 65 | if image_path is not None: 66 | # load the image if the path was passed 67 | image = mrcfile.read(image_path).astype(float) 68 | volume_layer = viewer.add_image(image) 69 | else: 70 | volume_layer = None 71 | 72 | if mesh_path is not None: 73 | # load the mesh if a path was passed 74 | mesh_data = read_obj_file(mesh_path) 75 | surface_layer = viewer.add_surface(mesh_data) 76 | else: 77 | surface_layer = None 78 | 79 | # Instantiate the widget and add it to Napari 80 | surforama_widget = QtSurforama(viewer, surface_layer, volume_layer) 81 | viewer.window.add_dock_widget( 82 | surforama_widget, area="right", name="Surforama" 83 | ) 84 | 85 | napari.run() 86 | -------------------------------------------------------------------------------- /src/surforama/_tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cellcanvas/surforama/15401ce542c3144dec34a7eac016703e970ab064/src/surforama/_tests/__init__.py -------------------------------------------------------------------------------- /src/surforama/_tests/test_geometry.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from surforama.utils.geometry import rotate_around_vector 4 | 5 | 6 | def test_rotate_around_axis_single_vector(): 7 | rotate_around = np.array([1, 0, 0]) 8 | to_rotate = np.array([0, 1, 0]) 9 | 10 | rotation_amount = np.pi / 2 11 | rotated_vector = rotate_around_vector( 12 | rotate_around=rotate_around, to_rotate=to_rotate, angle=rotation_amount 13 | ) 14 | 15 | assert np.allclose(rotated_vector, np.array([0, 0, 1])) 16 | 17 | 18 | def test_rotate_around_axis_multiple_vectors(): 19 | rotate_around = np.array([[1, 0, 0], [0, 1, 0]]) 20 | to_rotate = np.array([[0, 1, 0], [0, 0, 1]]) 21 | 22 | rotation_amount = np.pi / 2 23 | rotated_vector = rotate_around_vector( 24 | rotate_around=rotate_around, to_rotate=to_rotate, angle=rotation_amount 25 | ) 26 | 27 | assert np.allclose(rotated_vector, np.array([[0, 0, 1], [1, 0, 0]])) 28 | 29 | 30 | def test_rotate_around_axis_multiple_vectors_multiple_angles(): 31 | rotate_around = np.array([[1, 0, 0], [0, 1, 0]]) 32 | to_rotate = np.array([[0, 1, 0], [0, 0, 1]]) 33 | 34 | rotation_amount = np.array([np.pi / 2, np.pi]) 35 | rotated_vector = rotate_around_vector( 36 | rotate_around=rotate_around, to_rotate=to_rotate, angle=rotation_amount 37 | ) 38 | 39 | assert np.allclose(rotated_vector, np.array([[0, 0, 1], [0, 0, -1]])) 40 | -------------------------------------------------------------------------------- /src/surforama/_tests/test_widget.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from surforama.app import QtSurforama 4 | 5 | 6 | # capsys is a pytest fixture that captures stdout and stderr output streams 7 | def test_surforama(make_napari_viewer, capsys): 8 | # make viewer and add an image layer using our fixture 9 | viewer = make_napari_viewer() 10 | image_layer = viewer.add_image(np.random.random((20, 20, 20))) 11 | surface_layer = viewer.add_surface( 12 | ( 13 | np.random.random((100, 3)) * 20, 14 | (np.random.random((100, 3)) * 100).astype(int), 15 | ) 16 | ) 17 | 18 | # create our widget, passing in the viewer 19 | _ = QtSurforama(viewer, surface_layer, image_layer) 20 | -------------------------------------------------------------------------------- /src/surforama/app.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | import mrcfile 4 | import napari 5 | import numpy as np 6 | import trimesh 7 | from magicgui import magicgui 8 | from napari.layers import Image, Surface 9 | from qtpy.QtCore import Qt 10 | from qtpy.QtWidgets import ( 11 | QHBoxLayout, 12 | QLabel, 13 | QSlider, 14 | QVBoxLayout, 15 | QWidget, 16 | ) 17 | from scipy.ndimage import map_coordinates 18 | 19 | from surforama.gui.qt_mesh_generator import QtMeshGenerator 20 | from surforama.gui.qt_point_io import QtPointIO 21 | from surforama.gui.qt_surface_picker import QtSurfacePicker 22 | from surforama.io import read_obj_file 23 | 24 | 25 | class QtSurforama(QWidget): 26 | def __init__( 27 | self, 28 | viewer: napari.Viewer, 29 | surface_layer: Optional[Surface] = None, 30 | volume_layer: Optional[Image] = None, 31 | parent: Optional[QWidget] = None, 32 | ): 33 | super().__init__(parent=parent) 34 | self.viewer = viewer 35 | self._enabled = False 36 | 37 | # make the layer selection widget 38 | self._layer_selection_widget = magicgui( 39 | self._set_layers, 40 | surface_layer={"choices": self._get_valid_surface_layers}, 41 | image_layer={"choices": self._get_valid_image_layers}, 42 | call_button="start surfing", 43 | ) 44 | 45 | # add callback to update choices 46 | self.viewer.layers.events.inserted.connect(self._on_layer_update) 47 | self.viewer.layers.events.removed.connect(self._on_layer_update) 48 | 49 | # make the slider to change thickness 50 | self.slider = QSlider() 51 | self.slider.setOrientation(Qt.Horizontal) 52 | self.slider.setMinimum(-100) 53 | self.slider.setMaximum(100) 54 | self.slider.setValue(0) 55 | self.slider.valueChanged.connect(self.slide_points) 56 | self.slider.setVisible(False) 57 | self.slider_value = QLabel("0 vx", self) 58 | self.slider_value.setVisible(False) 59 | self.slider_title = QLabel("Extend/contract surface") 60 | self.slider_title.setVisible(False) 61 | 62 | self.sliderLayout = QHBoxLayout() 63 | self.sliderLayout.addWidget(self.slider) 64 | self.sliderLayout.addWidget(self.slider_value) 65 | 66 | # New slider for sampling depth 67 | 68 | self.sampling_depth_slider = QSlider() 69 | self.sampling_depth_slider.setOrientation(Qt.Horizontal) 70 | self.sampling_depth_slider.setMinimum(1) 71 | self.sampling_depth_slider.setMaximum(100) 72 | self.sampling_depth_slider.setValue(10) 73 | self.sampling_depth_slider.valueChanged.connect( 74 | self.update_colors_based_on_sampling 75 | ) 76 | self.sampling_depth_slider.setVisible(False) 77 | self.sampling_depth_value = QLabel("10", self) 78 | self.sampling_depth_value.setVisible(False) 79 | self.sampling_depth_title = QLabel("Surface Thickness") 80 | self.sampling_depth_title.setVisible(False) 81 | 82 | self.sampling_depth_sliderLayout = QHBoxLayout() 83 | self.sampling_depth_sliderLayout.addWidget(self.sampling_depth_slider) 84 | self.sampling_depth_sliderLayout.addWidget(self.sampling_depth_value) 85 | 86 | # make the picking widget 87 | self.picking_widget = QtSurfacePicker(surforama=self, parent=self) 88 | self.picking_widget.setVisible(False) 89 | 90 | # make the saving widget 91 | self.point_writer_widget = QtPointIO( 92 | surface_picker=self.picking_widget, parent=self 93 | ) 94 | self.point_writer_widget.setVisible(False) 95 | 96 | self.mesh_generator_widget = QtMeshGenerator(viewer, parent=self) 97 | 98 | # make the layout 99 | self.setLayout(QVBoxLayout()) 100 | self.layout().addWidget(self.mesh_generator_widget) 101 | self.layout().addWidget(self._layer_selection_widget.native) 102 | # self.layout().addWidget(QLabel("Extend/contract surface")) 103 | self.layout().addWidget(self.slider_title) 104 | self.layout().addLayout(self.sliderLayout) 105 | # self.layout().addWidget(QLabel("Surface Thickness")) 106 | self.layout().addWidget(self.sampling_depth_title) 107 | self.layout().addLayout(self.sampling_depth_sliderLayout) 108 | self.layout().addWidget(self.picking_widget) 109 | self.layout().addWidget(self.point_writer_widget) 110 | self.layout().addStretch() 111 | 112 | # set the layers 113 | self._set_layers(surface_layer=surface_layer, image_layer=volume_layer) 114 | 115 | @property 116 | def enabled(self) -> bool: 117 | return self._enabled 118 | 119 | @enabled.setter 120 | def enabled(self, enabled: bool): 121 | if enabled == self.enabled: 122 | # no change 123 | return 124 | 125 | if enabled: 126 | # make the widgets visible 127 | self.slider.setVisible(True) 128 | self.sampling_depth_slider.setVisible(True) 129 | self.picking_widget.setVisible(True) 130 | self.point_writer_widget.setVisible(True) 131 | self.slider_title.setVisible(True) 132 | self.slider_value.setVisible(True) 133 | self.sampling_depth_title.setVisible(True) 134 | self.sampling_depth_value.setVisible(True) 135 | else: 136 | # make the widgets visible 137 | self.slider.setVisible(False) 138 | self.sampling_depth_slider.setVisible(False) 139 | self.picking_widget.setVisible(False) 140 | self.point_writer_widget.setVisible(False) 141 | self.slider_title.setVisible(False) 142 | self.slider_value.setVisible(False) 143 | self.sampling_depth_title.setVisible(False) 144 | self.sampling_depth_value.setVisible(False) 145 | 146 | self._enabled = enabled 147 | 148 | def _set_layers( 149 | self, 150 | surface_layer: Surface, 151 | image_layer: Image, 152 | ): 153 | self.surface_layer = surface_layer 154 | self.image_layer = image_layer 155 | 156 | if (surface_layer is None) or (image_layer is None): 157 | return 158 | 159 | # Create a mesh object using trimesh 160 | self.mesh = trimesh.Trimesh( 161 | vertices=surface_layer.data[0], faces=surface_layer.data[1] 162 | ) 163 | 164 | self.vertices = self.mesh.vertices 165 | self.faces = self.mesh.faces 166 | 167 | self.volume = image_layer.data.astype(np.float32) 168 | self.normals = self.mesh.vertex_normals 169 | 170 | # Compute vertex values 171 | self.color_values = self.get_point_colors(self.get_point_set()) 172 | 173 | self.surface_layer.data = ( 174 | self.vertices, 175 | self.faces, 176 | self.color_values, 177 | ) 178 | self.surface_layer.shading = "none" 179 | self.surface_layer.refresh() 180 | 181 | self.enabled = True 182 | 183 | def _get_valid_surface_layers(self, combo_box) -> List[Surface]: 184 | return [ 185 | layer 186 | for layer in self._viewer.layers 187 | if isinstance(layer, napari.layers.Surface) 188 | ] 189 | 190 | def _on_layer_update(self, event=None): 191 | """When the model updates the selected layer, update widgets.""" 192 | self._layer_selection_widget.reset_choices() 193 | 194 | # check if the stored layers are still around 195 | layer_deleted = False 196 | if ( 197 | self.surface_layer is not None 198 | ) and self.surface_layer not in self.viewer.layers: 199 | # remove the surface layer if it has been deleted. 200 | self.surface_layer = None 201 | layer_deleted = True 202 | 203 | if (self.image_layer is not None) and ( 204 | self.image_layer not in self.viewer.layers 205 | ): 206 | # remove the surface layer if it has been deleted. 207 | self.image_layer = None 208 | layer_deleted = True 209 | 210 | if layer_deleted: 211 | self.enabled = False 212 | 213 | def _get_valid_image_layers(self, combo_box) -> List[Image]: 214 | return [ 215 | layer 216 | for layer in self._viewer.layers 217 | if isinstance(layer, napari.layers.Image) 218 | ] 219 | 220 | def get_point_colors(self, points): 221 | point_values = map_coordinates( 222 | self.volume, points.T, order=1, mode="nearest" 223 | ) 224 | 225 | normalized_values = (point_values - point_values.min()) / ( 226 | point_values.max() - point_values.min() + np.finfo(float).eps 227 | ) 228 | return normalized_values 229 | 230 | def get_point_set(self): 231 | return self.mesh.vertices 232 | 233 | def get_faces(self): 234 | return self.mesh.faces 235 | 236 | def slide_points(self, value): 237 | # Calculate the new positions of points along their normals 238 | shift = value / 10 239 | new_positions = self.get_point_set() + (self.normals * shift) 240 | # Update the points layer with new positions 241 | new_colors = self.get_point_colors(new_positions) 242 | 243 | vol_shape = self.volume.shape 244 | 245 | new_positions[:, 0] = np.clip(new_positions[:, 0], 0, vol_shape[0] - 1) 246 | new_positions[:, 1] = np.clip(new_positions[:, 1], 0, vol_shape[1] - 1) 247 | new_positions[:, 2] = np.clip(new_positions[:, 2], 0, vol_shape[2] - 1) 248 | 249 | self.color_values = new_colors 250 | self.vertices = new_positions 251 | self.update_mesh() 252 | self.slider_value.setText(f"{shift} vx") 253 | 254 | def update_mesh(self): 255 | self.surface_layer.data = ( 256 | self.vertices, 257 | self.get_faces(), 258 | self.color_values, 259 | ) 260 | 261 | def update_colors_based_on_sampling(self, value): 262 | spacing = 0.5 263 | sampling_depth = value / 10 264 | 265 | # Collect all samples for normalization calculation 266 | all_samples = [] 267 | 268 | # Sample along the normal for each point 269 | for point, normal in zip(self.get_point_set(), self.normals): 270 | for depth in range(int(sampling_depth)): 271 | sample_point = point + normal * spacing * depth 272 | sample_point_clipped = np.clip( 273 | sample_point, [0, 0, 0], np.array(self.volume.shape) - 1 274 | ).astype(int) 275 | sample_value = self.volume[ 276 | sample_point_clipped[0], 277 | sample_point_clipped[1], 278 | sample_point_clipped[2], 279 | ] 280 | all_samples.append(sample_value) 281 | 282 | # Calculate min and max across all sampled values 283 | samples_min = np.min(all_samples) 284 | samples_max = np.max(all_samples) 285 | 286 | # Normalize and update colors based on the mean value 287 | # of samples for each point 288 | new_colors = np.zeros((len(self.get_point_set()),)) 289 | for i, (point, normal) in enumerate( 290 | zip(self.get_point_set(), self.normals) 291 | ): 292 | samples = [] 293 | for depth in range(int(sampling_depth)): 294 | sample_point = point + normal * spacing * depth 295 | sample_point_clipped = np.clip( 296 | sample_point, [0, 0, 0], np.array(self.volume.shape) - 1 297 | ).astype(int) 298 | sample_value = self.volume[ 299 | sample_point_clipped[0], 300 | sample_point_clipped[1], 301 | sample_point_clipped[2], 302 | ] 303 | samples.append(sample_value) 304 | 305 | # Normalize the mean of samples for this point using 306 | # the min and max from all samples 307 | mean_value = np.mean(samples) 308 | normalized_value = ( 309 | (mean_value - samples_min) / (samples_max - samples_min) 310 | if samples_max > samples_min 311 | else 0 312 | ) 313 | new_colors[i] = normalized_value 314 | 315 | self.color_values = new_colors 316 | self.update_mesh() 317 | self.sampling_depth_value.setText(f"{value}") 318 | 319 | 320 | if __name__ == "__main__": 321 | 322 | obj_path = "../../examples/tomo_17_M10_grow1_1_mesh_data.obj" 323 | tomo_path = "../../examples/tomo_17_M10_grow1_1_mesh_data.mrc" 324 | 325 | mrc = mrcfile.open(tomo_path, permissive=True) 326 | tomo_mrc = np.array(mrc.data) 327 | 328 | vertices, faces, values = read_obj_file(obj_path) 329 | surface = (vertices, faces, values) 330 | 331 | viewer = napari.Viewer(ndisplay=3) 332 | volume_layer = viewer.add_image(tomo_mrc) 333 | surface_layer = viewer.add_surface(surface) 334 | 335 | # Testing points 336 | 337 | point_set = surface[0] 338 | 339 | volume_shape = np.array(tomo_mrc.data.shape) 340 | points_indices = np.round(point_set).astype(int) 341 | 342 | # Instantiate the widget and add it to Napari 343 | surforama_widget = QtSurforama(viewer, surface_layer, volume_layer) 344 | viewer.window.add_dock_widget( 345 | surforama_widget, area="right", name="Surforama" 346 | ) 347 | 348 | napari.run() 349 | -------------------------------------------------------------------------------- /src/surforama/constants.py: -------------------------------------------------------------------------------- 1 | # Relion star table names for particle coordinates 2 | STAR_X_COLUMN_NAME = "rlnCoordinateX" 3 | STAR_Y_COLUMN_NAME = "rlnCoordinateY" 4 | STAR_Z_COLUMN_NAME = "rlnCoordinateZ" 5 | STAR_ROTATION_0 = "rlnAngleRot" 6 | STAR_ROTATION_1 = "rlnAngleTilt" 7 | STAR_ROTATION_2 = "rlnAnglePsi" 8 | 9 | 10 | # column names for napari points layer for oriented particles 11 | NAPARI_NORMAL_0 = "normal-0" 12 | NAPARI_NORMAL_1 = "normal-1" 13 | NAPARI_NORMAL_2 = "normal-2" 14 | NAPARI_UP_0 = "up-0" 15 | NAPARI_UP_1 = "up-1" 16 | NAPARI_UP_2 = "up-2" 17 | ROTATION = "rotation" 18 | -------------------------------------------------------------------------------- /src/surforama/data/__init__.py: -------------------------------------------------------------------------------- 1 | from surforama.data._datasets import thylakoid_membrane 2 | 3 | __all__ = ("thylakoid_membrane",) 4 | -------------------------------------------------------------------------------- /src/surforama/data/_datasets.py: -------------------------------------------------------------------------------- 1 | from typing import List, Tuple 2 | 3 | import mrcfile 4 | import numpy as np 5 | import pooch 6 | from napari.types import LayerDataTuple 7 | 8 | from surforama.io.mesh import read_obj_file 9 | 10 | _thylakoid_registry = pooch.create( 11 | path=pooch.os_cache("cellcanvas"), 12 | base_url="doi:10.5281/zenodo.10814409", 13 | registry={ 14 | "S1_M3b_StII_grow2_1_mesh_data.mrc": "md5:a6e34bbf4edc767aa6c2c854c81c9c97", 15 | "S1_M3b_StII_grow2_1_mesh_data.obj": "md5:63b7d681204d7d3a3937154a0f4d7fc1", 16 | "S1_M3b_StII_grow2_1_mesh_data_seg.mrc": "md5:d88460eb3bdf3164be6053d281fc45be", 17 | "S1_M3c_StOI_grow2_1_mesh_data.mrc": "md5:296fbc48917c2baab7784a5ede6aae70", 18 | "S1_M3c_StOI_grow2_1_mesh_data.obj": "md5:076e6e8a825f67a24e28beba09edcf70", 19 | "S1_M3c_StOI_grow2_1_mesh_data_seg.mrc": "md5:878d4b3fc076dfc01e788cc08f9c9201", 20 | }, 21 | ) 22 | 23 | _covid_registry = pooch.create( 24 | path=pooch.os_cache("cellcanvas"), 25 | base_url="doi:10.5281/zenodo.10837518", 26 | registry={ 27 | "TS_004_dose-filt_lp50_bin8.rec": "md5:31914b3aa32c5656acf583f08a581f64", 28 | "TS_004_dose-filt_lp50_bin8_membrain_model.obj": "md5:9e67ab9493096ce6455e244ca6b20220", 29 | }, 30 | ) 31 | 32 | 33 | def thylakoid_membrane() -> ( 34 | Tuple[np.ndarray, Tuple[np.ndarray, np.ndarray, np.ndarray]] 35 | ): 36 | """Fetch the thylakoid membrane sample data. 37 | 38 | Data originally from Wietrzynski and Schaffer et al., eLife, 2020. 39 | https://doi.org/10.7554/eLife.53740 40 | """ 41 | # get the tomogram 42 | tomogram_path = _thylakoid_registry.fetch( 43 | "S1_M3b_StII_grow2_1_mesh_data.mrc", progressbar=True 44 | ) 45 | tomogram = mrcfile.read(tomogram_path).astype(float) 46 | 47 | # get the mesh 48 | mesh_path = _thylakoid_registry.fetch( 49 | "S1_M3b_StII_grow2_1_mesh_data.obj", progressbar=True 50 | ) 51 | mesh_data = read_obj_file(mesh_path) 52 | 53 | return tomogram, mesh_data 54 | 55 | 56 | def _thylakoid_membrane_sample_data_plugin() -> List[LayerDataTuple]: 57 | """napari sample data plugin for thylakoid membrane dataset.""" 58 | 59 | # get the data 60 | tomogram, mesh_data = thylakoid_membrane() 61 | 62 | return [ 63 | (tomogram, {"name": "tomogram"}, "image"), 64 | (mesh_data, {"name": "mesh"}, "surface"), 65 | ] 66 | 67 | 68 | def covid_membrane() -> ( 69 | Tuple[np.ndarray, Tuple[np.ndarray, np.ndarray, np.ndarray]] 70 | ): 71 | """Fetch the covid membrane sample data. 72 | 73 | Data originally from Ke et al., Nature, 2020. 74 | https://doi.org/10.5281/zenodo.10837519 75 | """ 76 | # get the tomogram 77 | tomogram_path = _covid_registry.fetch( 78 | "TS_004_dose-filt_lp50_bin8.rec", progressbar=True 79 | ) 80 | tomogram = mrcfile.read(tomogram_path).astype(float) 81 | 82 | # get the mesh 83 | mesh_path = _covid_registry.fetch( 84 | "TS_004_dose-filt_lp50_bin8_membrain_model.obj", progressbar=True 85 | ) 86 | mesh_data = read_obj_file(mesh_path) 87 | 88 | return tomogram, mesh_data 89 | 90 | 91 | def _covid_membrane_sample_data_plugin() -> List[LayerDataTuple]: 92 | """napari sample data plugin for thylakoid membrane dataset.""" 93 | 94 | # get the data 95 | tomogram, mesh_data = covid_membrane() 96 | 97 | return [ 98 | (tomogram, {"name": "tomogram"}, "image"), 99 | (mesh_data, {"name": "mesh"}, "surface"), 100 | ] 101 | -------------------------------------------------------------------------------- /src/surforama/gui/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cellcanvas/surforama/15401ce542c3144dec34a7eac016703e970ab064/src/surforama/gui/__init__.py -------------------------------------------------------------------------------- /src/surforama/gui/qt_mesh_generator.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | import napari 4 | from magicgui import magicgui 5 | from napari.layers import Labels 6 | from qtpy.QtWidgets import QGroupBox, QVBoxLayout, QWidget 7 | 8 | from surforama.io import convert_mask_to_mesh 9 | 10 | 11 | class QtMeshGenerator(QGroupBox): 12 | def __init__( 13 | self, viewer: napari.Viewer, parent: Optional[QWidget] = None 14 | ): 15 | super().__init__("Generate Mesh from Labels", parent=parent) 16 | self.viewer = viewer 17 | 18 | # make the labels layer selection widget 19 | self.labels_layer_selection_widget = magicgui( 20 | self._generate_mesh_from_labels, 21 | labels_layer={"choices": self._get_valid_labels_layers}, 22 | barycentric_area={ 23 | "widget_type": "Slider", 24 | "min": 0.1, 25 | "max": 10.0, 26 | "value": 1.0, 27 | "step": 0.1, 28 | }, 29 | smoothing={ 30 | "widget_type": "Slider", 31 | "min": 0, 32 | "max": 1000, 33 | "value": 1000, 34 | }, 35 | call_button="Generate Mesh", 36 | ) 37 | 38 | # make the layout 39 | self.setLayout(QVBoxLayout()) 40 | self.layout().addWidget(self.labels_layer_selection_widget.native) 41 | 42 | # Add callback to update choices when layers change 43 | self.viewer.layers.events.inserted.connect(self._on_layer_update) 44 | self.viewer.layers.events.removed.connect(self._on_layer_update) 45 | 46 | def _on_layer_update(self, event=None): 47 | """Refresh the layer choices when layers are added or removed.""" 48 | self.labels_layer_selection_widget.reset_choices() 49 | 50 | def _get_valid_labels_layers(self, combo_box) -> List[Labels]: 51 | return [ 52 | layer 53 | for layer in self.viewer.layers 54 | if isinstance(layer, napari.layers.Labels) 55 | ] 56 | 57 | def _generate_mesh_from_labels( 58 | self, 59 | labels_layer: Labels, 60 | smoothing: int = 10, 61 | barycentric_area: float = 1.0, 62 | ): 63 | # Assuming create_mesh_from_mask exists and generates vertices, faces, and values 64 | vertices, faces, values = convert_mask_to_mesh( 65 | labels_layer.data, 66 | smoothing=smoothing, 67 | barycentric_area=barycentric_area, 68 | ) 69 | self.viewer.add_surface((vertices, faces, values)) 70 | -------------------------------------------------------------------------------- /src/surforama/gui/qt_point_io.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import TYPE_CHECKING, Optional 3 | 4 | from magicgui import magicgui 5 | from napari.utils.notifications import show_warning 6 | from qtpy.QtWidgets import QGroupBox, QTabWidget, QVBoxLayout, QWidget 7 | 8 | if TYPE_CHECKING: 9 | from surforama.gui.qt_surface_picker import QtSurfacePicker 10 | from surforama.io.star import ( 11 | load_points_layer_data_from_star_file, 12 | oriented_points_to_star_file, 13 | ) 14 | from surforama.utils.napari import vectors_data_from_points_data 15 | 16 | 17 | class QtPointIO(QGroupBox): 18 | def __init__( 19 | self, 20 | surface_picker: "QtSurfacePicker", 21 | parent: Optional[QWidget] = None, 22 | ): 23 | super().__init__("Save points", parent=parent) 24 | self.surface_picker = surface_picker 25 | 26 | # make the points saving widget 27 | self.file_saving_widget = magicgui( 28 | self._write_star_file, 29 | file_path={"mode": "w"}, 30 | call_button="Save to star file", 31 | ) 32 | self.file_loading_widget = magicgui( 33 | self._load_star_file, 34 | file_path={"mode": "r"}, 35 | call_button="Load from star file", 36 | ) 37 | 38 | # make the tab widget 39 | self.tab_widget = QTabWidget(self) 40 | self.tab_widget.addTab(self.file_saving_widget.native, "save") 41 | self.tab_widget.addTab(self.file_loading_widget.native, "load") 42 | 43 | # make the layout 44 | self.setLayout(QVBoxLayout()) 45 | self.layout().addWidget(self.tab_widget) 46 | 47 | def _write_star_file(self, file_path: Path): 48 | oriented_points_to_star_file( 49 | points_layer=self.surface_picker.points_layer, 50 | output_path=file_path, 51 | ) 52 | 53 | def _load_star_file(self, file_path: Path): 54 | """Load oriented points from a star file and add them 55 | to the viewer. 56 | """ 57 | if self.surface_picker.enabled is False: 58 | show_warning("The surface picker must be enabled to load a file.") 59 | return 60 | # get the points data 61 | point_coordinates, features_table = ( 62 | load_points_layer_data_from_star_file(file_path=file_path) 63 | ) 64 | 65 | # get the vectors data 66 | normal_data, up_data = vectors_data_from_points_data( 67 | point_coordinates=point_coordinates, features_table=features_table 68 | ) 69 | 70 | # add the data to the viewer 71 | with self.surface_picker.points_layer.events.data.blocker(): 72 | self.surface_picker.points_layer.data = point_coordinates 73 | self.surface_picker.points_layer.features = features_table 74 | 75 | self.surface_picker.normal_vectors_layer.data = normal_data 76 | self.surface_picker.up_vectors_layer.data = up_data 77 | 78 | self.surface_picker.normal_vectors_layer.edge_color = "purple" 79 | self.surface_picker.up_vectors_layer.edge_color = "orange" 80 | -------------------------------------------------------------------------------- /src/surforama/gui/qt_surface_picker.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Optional 2 | 3 | import napari 4 | import numpy as np 5 | from qtpy.QtCore import Qt 6 | from qtpy.QtWidgets import ( 7 | QGroupBox, 8 | QPushButton, 9 | QVBoxLayout, 10 | QWidget, 11 | ) 12 | from superqt import QLabeledDoubleSlider 13 | 14 | if TYPE_CHECKING: 15 | from surforama import QtSurforama 16 | from surforama.constants import ( 17 | NAPARI_NORMAL_0, 18 | NAPARI_NORMAL_1, 19 | NAPARI_NORMAL_2, 20 | NAPARI_UP_0, 21 | NAPARI_UP_1, 22 | NAPARI_UP_2, 23 | ROTATION, 24 | ) 25 | from surforama.utils.geometry import rotate_around_vector 26 | from surforama.utils.napari import ( 27 | update_rotations_on_points_layer, 28 | vectors_data_from_points_layer, 29 | ) 30 | 31 | 32 | class QtSurfacePicker(QGroupBox): 33 | ENABLE_BUTTON_TEXT = "Enable" 34 | DISABLE_BUTTON_TEXT = "Disable" 35 | 36 | def __init__( 37 | self, surforama: "QtSurforama", parent: Optional[QWidget] = None 38 | ): 39 | super().__init__("Pick on surface", parent=parent) 40 | self.surforama = surforama 41 | self.points_layer = None 42 | self.normal_vectors_layer = None 43 | self.up_vectors_layer = None 44 | 45 | self.surforama.viewer.layers.events.removed.connect( 46 | self._on_layer_update 47 | ) 48 | 49 | # initialize orientation data 50 | # todo store elsewhere (e.g., layer features) 51 | self.normal_vectors = np.empty((0, 3)) 52 | self.up_vectors = np.empty((0, 3)) 53 | self.rotations = np.empty((0,)) 54 | 55 | # enable state 56 | self._enabled = False 57 | 58 | # make the activate button 59 | self.enable_button = QPushButton(self.ENABLE_BUTTON_TEXT) 60 | self.enable_button.clicked.connect(self._on_enable_button_pressed) 61 | 62 | # make the rotation slider 63 | self.rotation_slider = QLabeledDoubleSlider(Qt.Orientation.Horizontal) 64 | self.rotation_slider.setMinimum(-180) 65 | self.rotation_slider.setMaximum(180) 66 | self.rotation_slider.setValue(0) 67 | self.rotation_slider.valueChanged.connect(self._update_rotation) 68 | 69 | # make the layout 70 | self.setLayout(QVBoxLayout()) 71 | self.layout().addWidget(self.enable_button) 72 | self.layout().addWidget(self.rotation_slider) 73 | 74 | @property 75 | def enabled(self) -> bool: 76 | return self._enabled 77 | 78 | @enabled.setter 79 | def enabled(self, enabled: bool): 80 | if enabled == self.enabled: 81 | # do nothing 82 | return 83 | if enabled: 84 | self.enable_button.setText(self.DISABLE_BUTTON_TEXT) 85 | 86 | if self.points_layer is None: 87 | self._initialize_points_layer() 88 | if self.normal_vectors_layer is None: 89 | self._initialize_normal_vectors_layers() 90 | if self.up_vectors_layer is None: 91 | self._initialize_up_vectors_layers() 92 | self.points_layer.visible = True 93 | 94 | # update the vectors layer 95 | self._on_points_update() 96 | 97 | # add the mouse callbacks 98 | self._connect_mouse_callbacks() 99 | 100 | else: 101 | self.enable_button.setText(self.ENABLE_BUTTON_TEXT) 102 | 103 | # remove the mouse callbacks 104 | self._disconnect_mouse_callbacks() 105 | 106 | self._enabled = enabled 107 | 108 | def _on_layer_update(self): 109 | # check if the stored layers are still around 110 | viewer = self.surforama.viewer 111 | layer_deleted = False 112 | if (self.points_layer is not None) and ( 113 | self.points_layer not in viewer.layers 114 | ): 115 | # remove the surface layer if it has been deleted. 116 | self.points_layer = None 117 | layer_deleted = True 118 | 119 | if (self.normal_vectors_layer is not None) and ( 120 | self.normal_vectors_layer not in viewer.layers 121 | ): 122 | # remove the surface layer if it has been deleted. 123 | self.normal_vectors_layer = None 124 | layer_deleted = True 125 | 126 | if (self.up_vectors_layer is not None) and ( 127 | self.up_vectors_layer not in viewer.layers 128 | ): 129 | # remove the surface layer if it has been deleted. 130 | self.up_vectors_layer = None 131 | layer_deleted = True 132 | 133 | if layer_deleted: 134 | self.enabled = False 135 | 136 | def _on_enable_button_pressed(self, event): 137 | # toggle enabled 138 | if self.enabled: 139 | # if currently enabled, toggle to disabled 140 | self.enabled = False 141 | 142 | else: 143 | # if disabled, toggle to enabled 144 | self.enabled = True 145 | 146 | def _update_rotation(self, value): 147 | """Callback function to update the rotation of the selected points.""" 148 | selected_points = list(self.points_layer.selected_data) 149 | self.rotations[selected_points] = value 150 | 151 | rotation_radians = value * (np.pi / 180) 152 | new_rotations = rotation_radians * np.ones( 153 | len(selected_points), dtype=float 154 | ) 155 | 156 | old_up_vector = self.up_vectors[selected_points] 157 | normal_vector = self.normal_vectors[selected_points] 158 | 159 | new_up_vector = rotate_around_vector( 160 | rotate_around=normal_vector, 161 | to_rotate=old_up_vector, 162 | angle=rotation_radians, 163 | ) 164 | 165 | update_rotations_on_points_layer( 166 | points_layer=self.points_layer, 167 | point_index=selected_points, 168 | rotations=new_rotations, 169 | ) 170 | 171 | self.up_vectors_layer.data[selected_points, 1, :] = new_up_vector 172 | self.up_vectors_layer.refresh() 173 | 174 | def _initialize_points_layer(self): 175 | self.points_layer = self.surforama.viewer.add_points( 176 | ndim=3, size=3, face_color="magenta" 177 | ) 178 | self.points_layer.shading = "spherical" 179 | self.points_layer.events.data.connect(self._on_points_update) 180 | self.points_layer.selected_data.events.items_changed.connect( 181 | self._on_point_selection 182 | ) 183 | self.surforama.viewer.layers.selection = [self.surforama.surface_layer] 184 | 185 | def _initialize_normal_vectors_layers(self): 186 | self.normal_vectors_layer = self.surforama.viewer.add_vectors( 187 | ndim=3, 188 | length=10, 189 | edge_color="cornflowerblue", 190 | name="surface normals", 191 | ) 192 | self.surforama.viewer.layers.selection = [self.surforama.surface_layer] 193 | 194 | def _initialize_up_vectors_layers(self): 195 | self.up_vectors_layer = self.surforama.viewer.add_vectors( 196 | ndim=3, length=10, edge_color="orange", name="up vectors" 197 | ) 198 | self.surforama.viewer.layers.selection = [self.surforama.surface_layer] 199 | 200 | def _on_points_update(self, event=None): 201 | """Update the vectors layers when the points data are updated.""" 202 | # update the vectors 203 | normal_data, up_data = vectors_data_from_points_layer( 204 | self.points_layer 205 | ) 206 | self.normal_vectors_layer.data = normal_data 207 | self.up_vectors_layer.data = up_data 208 | 209 | # colors were being reset - this might not be necessary 210 | self.normal_vectors_layer.edge_color = "purple" 211 | self.up_vectors_layer.edge_color = "orange" 212 | 213 | def _on_point_selection(self, event=None): 214 | selected_points = list(self.points_layer.selected_data) 215 | if len(selected_points) == 0: 216 | # no points selected 217 | return 218 | rotation_column = self.points_layer.features.columns.get_loc(ROTATION) 219 | rotations = self.points_layer.features.iloc[ 220 | selected_points, rotation_column 221 | ].to_numpy() 222 | rotation = rotations[0] 223 | 224 | self.rotation_slider.blockSignals(True) 225 | self.rotation_slider.setValue((180 / np.pi) * rotation) 226 | self.rotation_slider.blockSignals(False) 227 | 228 | def _connect_mouse_callbacks(self): 229 | self.surforama.surface_layer.mouse_drag_callbacks.append( 230 | self._find_point_on_click 231 | ) 232 | 233 | def _disconnect_mouse_callbacks(self): 234 | self.surforama.surface_layer.mouse_drag_callbacks.remove( 235 | self._find_point_on_click 236 | ) 237 | 238 | def _find_point_on_click(self, layer, event): 239 | # if "Alt" not in event.modifiers: 240 | # return 241 | value = layer.get_value( 242 | event.position, 243 | view_direction=event.view_direction, 244 | dims_displayed=event.dims_displayed, 245 | world=True, 246 | ) 247 | if value is None: 248 | return 249 | triangle_index = value[1] 250 | if triangle_index is None: 251 | # if the click did not intersect the mesh, don't do anything 252 | return 253 | 254 | # get the intersection point 255 | candidate_vertices = layer.data[1][triangle_index] 256 | candidate_points = layer.data[0][candidate_vertices] 257 | ( 258 | _, 259 | intersection_coords, 260 | ) = napari.utils.geometry.find_nearest_triangle_intersection( 261 | event.position, event.view_direction, candidate_points[None, :, :] 262 | ) 263 | 264 | # get normal vector of intersected triangle 265 | mesh = self.surforama.mesh 266 | normal_vector = mesh.face_normals[triangle_index] 267 | 268 | # create the orientation coordinate system 269 | up_vector = np.cross( 270 | normal_vector, [1, 0, 0] 271 | ) # todo add check if normal is parallel 272 | 273 | # store the data 274 | feature_table = self.points_layer._feature_table 275 | table_defaults = feature_table.defaults 276 | table_defaults[NAPARI_NORMAL_0] = normal_vector[0] 277 | table_defaults[NAPARI_NORMAL_1] = normal_vector[1] 278 | table_defaults[NAPARI_NORMAL_2] = normal_vector[2] 279 | table_defaults[NAPARI_UP_0] = up_vector[0] 280 | table_defaults[NAPARI_UP_1] = up_vector[1] 281 | table_defaults[NAPARI_UP_2] = up_vector[2] 282 | table_defaults[ROTATION] = 0.0 283 | self.normal_vectors = np.concatenate( 284 | (self.normal_vectors, np.atleast_2d(normal_vector)) 285 | ) 286 | self.up_vectors = np.concatenate( 287 | (self.up_vectors, np.atleast_2d(up_vector)) 288 | ) 289 | self.rotations = np.append(self.rotations, 0) 290 | 291 | with self.points_layer.events.data.blocker(self._on_points_update): 292 | # we block since the event emission is before the features are updated. 293 | self.points_layer.add(np.atleast_2d(intersection_coords)) 294 | self._on_points_update() 295 | -------------------------------------------------------------------------------- /src/surforama/io/__init__.py: -------------------------------------------------------------------------------- 1 | from surforama.io.mesh import convert_mask_to_mesh, read_obj_file 2 | 3 | __all__ = ("read_obj_file", "convert_mask_to_mesh") 4 | -------------------------------------------------------------------------------- /src/surforama/io/_reader_plugin.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, List 2 | 3 | from napari.types import LayerDataTuple 4 | 5 | from surforama.io import read_obj_file 6 | from surforama.io.star import load_points_layer_data_from_star_file 7 | from surforama.utils.napari import vectors_data_from_points_data 8 | 9 | 10 | def mesh_reader_plugin(path: str) -> Callable[[str], LayerDataTuple]: 11 | if isinstance(path, str) and path.endswith(".obj"): 12 | return obj_reader 13 | 14 | # format not recognized 15 | return None 16 | 17 | 18 | def obj_reader(path: str) -> List[LayerDataTuple]: 19 | mesh_data = read_obj_file(path) 20 | return [(mesh_data, {}, "surface")] 21 | 22 | 23 | def star_reader_plugin(path: str) -> Callable[[str], LayerDataTuple]: 24 | if isinstance(path, str) and path.endswith(".star"): 25 | return star_reader 26 | 27 | # format not recognized 28 | return None 29 | 30 | 31 | def star_reader(path: str) -> List[LayerDataTuple]: 32 | point_coordinates, features_table = load_points_layer_data_from_star_file( 33 | file_path=path 34 | ) 35 | 36 | # make the points layer data 37 | points_layer_data = ( 38 | point_coordinates, 39 | {"size": 3, "face_color": "magenta", "features": features_table}, 40 | "points", 41 | ) 42 | 43 | # make the vectors layer data 44 | normal_data, up_data = vectors_data_from_points_data( 45 | point_coordinates=point_coordinates, features_table=features_table 46 | ) 47 | normal_layer_data = ( 48 | normal_data, 49 | {"length": 10, "edge_color": "orange", "name": "normal vectors"}, 50 | "vectors", 51 | ) 52 | up_layer_data = ( 53 | up_data, 54 | {"length": 10, "edge_color": "purple", "name": "up vectors"}, 55 | "vectors", 56 | ) 57 | 58 | return [points_layer_data, normal_layer_data, up_layer_data] 59 | -------------------------------------------------------------------------------- /src/surforama/io/_tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cellcanvas/surforama/15401ce542c3144dec34a7eac016703e970ab064/src/surforama/io/_tests/__init__.py -------------------------------------------------------------------------------- /src/surforama/io/_tests/test_star.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pandas as pd 3 | from napari.layers import Points 4 | 5 | from surforama.constants import ( 6 | NAPARI_NORMAL_0, 7 | NAPARI_NORMAL_1, 8 | NAPARI_NORMAL_2, 9 | NAPARI_UP_0, 10 | NAPARI_UP_1, 11 | NAPARI_UP_2, 12 | ROTATION, 13 | ) 14 | from surforama.io.star import ( 15 | load_points_layer_data_from_star_file, 16 | oriented_points_to_star_file, 17 | ) 18 | from surforama.utils.geometry import rotate_around_vector 19 | 20 | 21 | def test_star_file_round_trip(tmp_path): 22 | """Test saving and loading a star file is lossless.""" 23 | # make a points layer with an oriented points feature table 24 | point_coordinates = np.array([[10, 10, 10], [20, 20, 20]]) 25 | normal_vectors = np.array([[1, 0, 0], [1, 0, 0]]) 26 | up_vectors = np.array([[0, 0, 1], [0, 1, 0]]) 27 | rotations = np.array([0, np.pi / 2]) 28 | feature_table = pd.DataFrame( 29 | { 30 | NAPARI_NORMAL_0: normal_vectors[:, 0], 31 | NAPARI_NORMAL_1: normal_vectors[:, 1], 32 | NAPARI_NORMAL_2: normal_vectors[:, 2], 33 | NAPARI_UP_0: up_vectors[:, 0], 34 | NAPARI_UP_1: up_vectors[:, 1], 35 | NAPARI_UP_2: up_vectors[:, 2], 36 | ROTATION: rotations, 37 | } 38 | ) 39 | points_layer = Points(point_coordinates, features=feature_table) 40 | 41 | # save the feature table to a star file 42 | star_file_path = tmp_path / "test.star" 43 | oriented_points_to_star_file(points_layer, output_path=star_file_path) 44 | 45 | # load the star file 46 | returned_points_coordinates, returned_features_table = ( 47 | load_points_layer_data_from_star_file(star_file_path) 48 | ) 49 | 50 | # compare the point coordinates 51 | np.testing.assert_allclose(returned_points_coordinates, point_coordinates) 52 | 53 | # compare the normal vectors 54 | returned_normal_vectors = returned_features_table[ 55 | [NAPARI_NORMAL_0, NAPARI_NORMAL_1, NAPARI_NORMAL_2] 56 | ] 57 | np.testing.assert_allclose(returned_normal_vectors, normal_vectors) 58 | 59 | # compare the up vectors 60 | expected_up_vectors = rotate_around_vector( 61 | rotate_around=normal_vectors, to_rotate=up_vectors, angle=rotations 62 | ) 63 | returned_up_vectors = returned_features_table[ 64 | [NAPARI_UP_0, NAPARI_UP_1, NAPARI_UP_2] 65 | ].to_numpy() 66 | np.testing.assert_allclose( 67 | returned_up_vectors, expected_up_vectors, atol=1e-15 68 | ) 69 | -------------------------------------------------------------------------------- /src/surforama/io/mesh.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pyacvd 3 | import pyvista as pv 4 | import trimesh 5 | from skimage.measure import marching_cubes 6 | 7 | 8 | def read_obj_file(file_path): 9 | mesh = trimesh.load(file_path, file_type="obj", process=True) 10 | 11 | # Subdivide 12 | # verts, faces = trimesh.remesh.subdivide_to_size( 13 | # mesh.vertices, mesh.faces, 1 14 | # ) 15 | 16 | # Subdivide can introduce holes 17 | # mesh = trimesh.Trimesh(vertices=verts, faces=faces) 18 | # trimesh.repair.fill_holes(mesh) 19 | 20 | verts = mesh.vertices 21 | faces = mesh.faces 22 | 23 | # trimesh swaps coordinate axes 24 | verts = verts[:, [2, 1, 0]] 25 | 26 | values = np.ones((len(verts),)) 27 | 28 | return verts, faces, values 29 | 30 | 31 | def convert_mask_to_mesh( 32 | mask: np.ndarray, 33 | barycentric_area: float = 1.0, 34 | smoothing: int = 10, 35 | ): 36 | """ 37 | Convert a binary mask to a mesh. 38 | 39 | Parameters 40 | ---------- 41 | mask : np.ndarray 42 | A binary mask. 43 | barycentric_area : float, optional 44 | The target barycentric area of each vertex in the mesh, 45 | by default 1.0 46 | smoothing : int, optional 47 | Number of iterations for Laplacian mesh smoothing, 48 | by default 10 49 | """ 50 | verts, faces, _, _ = marching_cubes( 51 | volume=mask, 52 | level=0.5, 53 | step_size=1, 54 | method="lewiner", 55 | ) 56 | 57 | # Prepend 3 for pyvista format 58 | faces = np.concatenate( 59 | (np.ones((faces.shape[0], 1), dtype=int) * 3, faces), axis=1 60 | ) 61 | 62 | # Create a mesh 63 | surf = pv.PolyData(verts, faces) 64 | surf = surf.smooth(n_iter=smoothing) 65 | 66 | # remesh to desired point size 67 | cluster_points = int(surf.area / barycentric_area) 68 | clus = pyacvd.Clustering(surf) 69 | clus.subdivide(3) 70 | clus.cluster(cluster_points) 71 | remesh = clus.create_mesh() 72 | 73 | verts = remesh.points 74 | faces = remesh.faces.reshape(-1, 4)[:, 1:] 75 | values = np.ones((len(verts),)) 76 | 77 | # switch face order to have outward normals 78 | faces = faces[:, [0, 2, 1]] 79 | 80 | return verts, faces, values 81 | -------------------------------------------------------------------------------- /src/surforama/io/star.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Tuple, Union 3 | 4 | import numpy as np 5 | import pandas as pd 6 | import starfile 7 | from napari.layers import Points 8 | from scipy.spatial.transform import Rotation as R 9 | 10 | from surforama.constants import ( 11 | NAPARI_NORMAL_0, 12 | NAPARI_NORMAL_1, 13 | NAPARI_NORMAL_2, 14 | NAPARI_UP_0, 15 | NAPARI_UP_1, 16 | NAPARI_UP_2, 17 | ROTATION, 18 | STAR_ROTATION_0, 19 | STAR_ROTATION_1, 20 | STAR_ROTATION_2, 21 | STAR_X_COLUMN_NAME, 22 | STAR_Y_COLUMN_NAME, 23 | STAR_Z_COLUMN_NAME, 24 | ) 25 | from surforama.utils.geometry import rotate_around_vector 26 | 27 | 28 | def load_points_from_star_table(star_table: pd.DataFrame) -> np.ndarray: 29 | """Get point coordinates from a Relion-formatted star table. 30 | 31 | Currently this does not account for shifts. 32 | 33 | Parameters 34 | ---------- 35 | star_table : pd.DataFrame 36 | The table from which to extract the point coordinates 37 | """ 38 | return star_table[ 39 | [STAR_Z_COLUMN_NAME, STAR_Y_COLUMN_NAME, STAR_X_COLUMN_NAME] 40 | ].to_numpy() 41 | 42 | 43 | def load_orientations_from_star_table(star_table: pd.DataFrame): 44 | """Get orientations from a Relion-formatted star table.""" 45 | eulers = star_table[ 46 | ["rlnAngleRot", "rlnAngleTilt", "rlnAnglePsi"] 47 | ].to_numpy() 48 | return R.from_euler(seq="ZYZ", angles=eulers, degrees=True).inv() 49 | 50 | 51 | def load_points_layer_data_from_star_file( 52 | file_path: str, 53 | ) -> Tuple[np.ndarray, pd.DataFrame]: 54 | """Load an oriented Points layer from a Relion-formatted star file""" 55 | star_table = starfile.read(file_path) 56 | point_coordinates = load_points_from_star_table(star_table) 57 | orientations = load_orientations_from_star_table(star_table) 58 | 59 | # get the normal vectors 60 | initial_normal_vector = np.array([0, 0, 1]) 61 | normal_vectors = orientations.apply(initial_normal_vector)[:, ::-1] 62 | 63 | # get the up vectors 64 | initial_up_vector = np.array([0, 1, 0]) 65 | up_vectors = orientations.apply(initial_up_vector)[:, ::-1] 66 | 67 | # initialize the rotations 68 | n_points = point_coordinates.shape[0] 69 | rotations = np.zeros((n_points,)) 70 | 71 | feature_table = pd.DataFrame( 72 | { 73 | NAPARI_NORMAL_0: normal_vectors[:, 0], 74 | NAPARI_NORMAL_1: normal_vectors[:, 1], 75 | NAPARI_NORMAL_2: normal_vectors[:, 2], 76 | NAPARI_UP_0: up_vectors[:, 0], 77 | NAPARI_UP_1: up_vectors[:, 1], 78 | NAPARI_UP_2: up_vectors[:, 2], 79 | ROTATION: rotations, 80 | } 81 | ) 82 | 83 | return point_coordinates, feature_table 84 | 85 | 86 | def oriented_points_to_star_table(points_layer: Points): 87 | """points with orientations to a star-formatted DataFrame.""" 88 | # get the point coordinates 89 | napari_coordinates = points_layer.data 90 | relion_coordinates = napari_coordinates[:, ::-1] 91 | 92 | # get the point orientations 93 | features_table = points_layer.features 94 | normal_vectors = features_table[ 95 | [NAPARI_NORMAL_0, NAPARI_NORMAL_1, NAPARI_NORMAL_2] 96 | ].to_numpy() 97 | up_vectors = features_table[ 98 | [NAPARI_UP_0, NAPARI_UP_1, NAPARI_UP_2] 99 | ].to_numpy() 100 | rotations = features_table[ROTATION].to_numpy() 101 | rotated_up_vectors = rotate_around_vector( 102 | rotate_around=normal_vectors, to_rotate=up_vectors, angle=rotations 103 | ) 104 | third_basis = np.cross(normal_vectors, rotated_up_vectors) 105 | n_points = napari_coordinates.shape[0] 106 | particle_orientations = np.zeros((n_points, 3, 3)) 107 | particle_orientations[:, :, 0] = third_basis[:, ::-1] 108 | particle_orientations[:, :, 1] = rotated_up_vectors[:, ::-1] 109 | particle_orientations[:, :, 2] = normal_vectors[:, ::-1] 110 | euler_angles = ( 111 | R.from_matrix(particle_orientations) 112 | .inv() 113 | .as_euler(seq="ZYZ", degrees=True) 114 | ) 115 | 116 | return pd.DataFrame( 117 | { 118 | STAR_X_COLUMN_NAME: relion_coordinates[:, 0], 119 | STAR_Y_COLUMN_NAME: relion_coordinates[:, 1], 120 | STAR_Z_COLUMN_NAME: relion_coordinates[:, 2], 121 | STAR_ROTATION_0: euler_angles[:, 0], 122 | STAR_ROTATION_1: euler_angles[:, 1], 123 | STAR_ROTATION_2: euler_angles[:, 2], 124 | } 125 | ) 126 | 127 | 128 | def oriented_points_to_star_file( 129 | points_layer: Points, output_path: Union[str, Path] 130 | ): 131 | """points with orientations to a star file""" 132 | star_table = oriented_points_to_star_table(points_layer) 133 | starfile.write(star_table, output_path) 134 | -------------------------------------------------------------------------------- /src/surforama/napari.yaml: -------------------------------------------------------------------------------- 1 | name: surforama 2 | display_name: Surforama 3 | # use 'hidden' to remove plugin from napari hub search results 4 | visibility: hidden 5 | # see https://napari.org/stable/plugins/manifest.html for valid categories 6 | categories: ["Annotation", "Visualization"] 7 | contributions: 8 | commands: 9 | - id: surforama.make_widget 10 | python_name: surforama:QtSurforama 11 | title: Make Surforama 12 | - id: surforama.thylakoid 13 | python_name: surforama.data._datasets:_thylakoid_membrane_sample_data_plugin 14 | title: Thylakoid membrane 15 | - id: surforama.covid 16 | python_name: surforama.data._datasets:_covid_membrane_sample_data_plugin 17 | title: Covid virion membrane 18 | - id: surforama.mesh_reader 19 | python_name: surforama.io._reader_plugin:mesh_reader_plugin 20 | title: Mesh reader 21 | - id: surforama.star_reader 22 | python_name: surforama.io._reader_plugin:star_reader_plugin 23 | title: star reader 24 | sample_data: 25 | - command: surforama.thylakoid 26 | key: thylakoid 27 | display_name: thylakoid membrane 28 | - command: surforama.covid 29 | key: covid 30 | display_name: covid virion membrane 31 | readers: 32 | - command: surforama.mesh_reader 33 | filename_patterns: 34 | - '*.obj' 35 | accepts_directories: false 36 | - command: surforama.star_reader 37 | filename_patterns: 38 | - '*.star' 39 | accepts_directories: false 40 | widgets: 41 | - command: surforama.make_widget 42 | display_name: Surforama 43 | -------------------------------------------------------------------------------- /src/surforama/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cellcanvas/surforama/15401ce542c3144dec34a7eac016703e970ab064/src/surforama/utils/__init__.py -------------------------------------------------------------------------------- /src/surforama/utils/geometry.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | import numpy as np 4 | import trimesh 5 | from scipy.spatial.transform import Rotation as R 6 | 7 | 8 | def rotate_around_vector( 9 | rotate_around: np.ndarray, 10 | to_rotate: np.ndarray, 11 | angle: Union[float, np.ndarray], 12 | ) -> np.ndarray: 13 | """Rotate a vector around another vector a specified angle. 14 | 15 | Parameters 16 | ---------- 17 | rotate_around : np.ndarray 18 | The vector to rotate around. 19 | to_rotate : np.ndarray 20 | The vector to rotate. 21 | angle : Union[float, np.ndarray] 22 | The angle to perform the rotation in radians. 23 | Positive angles will rotate counter-clockwise. 24 | If provided as an array, each element should be indexed-matched 25 | with the vectors. 26 | 27 | Returns 28 | ------- 29 | np.ndarray 30 | The rotated vector. 31 | """ 32 | # ensure unit vectors 33 | rotate_around = rotate_around.astype(float) 34 | if rotate_around.ndim == 1: 35 | rotate_around /= np.linalg.norm(rotate_around) 36 | elif rotate_around.ndim == 2: 37 | # handle the 2D case 38 | rotate_around /= np.linalg.norm(rotate_around, axis=1)[..., np.newaxis] 39 | angle = np.asarray(angle)[..., np.newaxis] 40 | else: 41 | raise ValueError( 42 | "rotate_around must be a single vector as a 1D array or a 2D array of multiple vectors." 43 | ) 44 | 45 | rotation_vector = angle * rotate_around 46 | rotation_object = R.from_rotvec(rotation_vector) 47 | 48 | return rotation_object.apply(to_rotate) 49 | 50 | 51 | def find_closest_triangle(mesh: trimesh.Trimesh, point: np.ndarray) -> int: 52 | """ 53 | Find the index of the triangle in a mesh that is closest to a specified point. 54 | 55 | Parameters 56 | ---------- 57 | mesh : trimesh.Trimesh 58 | The mesh in which to find the closest triangle. 59 | point : np.ndarray 60 | A 3D point (as a NumPy array) for which the closest triangle is to be found. 61 | 62 | Returns 63 | ------- 64 | int 65 | The index of the triangle that is closest to the given point. 66 | 67 | Notes 68 | ----- 69 | This function calculates the geometric center of each triangle and finds the one 70 | closest to the specified point using Euclidean distance. 71 | """ 72 | triangle_centers = np.mean(mesh.vertices[mesh.faces], axis=1) 73 | distances = np.linalg.norm(triangle_centers - point, axis=1) 74 | return np.argmin(distances) 75 | 76 | 77 | def find_closest_normal( 78 | mesh: trimesh.Trimesh, point: np.ndarray 79 | ) -> np.ndarray: 80 | """ 81 | Find the normal of the closest triangle in a mesh to a specified point. 82 | 83 | Parameters 84 | ---------- 85 | mesh : trimesh.Trimesh 86 | The mesh from which to find the closest triangle normal. 87 | point : np.ndarray 88 | A 3D point (as a NumPy array) for which the closest triangle normal is to be found. 89 | 90 | Returns 91 | ------- 92 | np.ndarray 93 | The normal vector of the closest triangle to the given point. 94 | 95 | Notes 96 | ----- 97 | This function uses `find_closest_triangle` to determine the closest triangle and then 98 | retrieves the normal vector associated with that triangle from the mesh's `face_normals`. 99 | """ 100 | triangle_index = find_closest_triangle(mesh, point) 101 | face_normals = mesh.face_normals 102 | return face_normals[triangle_index] 103 | -------------------------------------------------------------------------------- /src/surforama/utils/napari.py: -------------------------------------------------------------------------------- 1 | from typing import List, Tuple, Union 2 | 3 | import numpy as np 4 | import pandas as pd 5 | from napari.layers import Points 6 | 7 | from surforama.constants import ( 8 | NAPARI_NORMAL_0, 9 | NAPARI_NORMAL_1, 10 | NAPARI_NORMAL_2, 11 | NAPARI_UP_0, 12 | NAPARI_UP_1, 13 | NAPARI_UP_2, 14 | ROTATION, 15 | ) 16 | from surforama.utils.geometry import rotate_around_vector 17 | 18 | 19 | def vectors_data_from_points_layer( 20 | points_layer: Points, 21 | ) -> Tuple[np.ndarray, np.ndarray]: 22 | """Get the vectors data for normal and up vectors from an oriented points layer. 23 | 24 | Parameters 25 | ---------- 26 | points_layer : Points 27 | The oriented points layer from which to get the vectors data. 28 | 29 | Returns 30 | ------- 31 | normals_data : np.ndarray 32 | The Vectors layer data for the normal vectors 33 | up_data : np.ndarray 34 | The Vectors layer data for the up vectors 35 | """ 36 | 37 | # get the point data 38 | point_coordinates = points_layer.data 39 | features_table = points_layer.features 40 | return vectors_data_from_points_data( 41 | point_coordinates=point_coordinates, features_table=features_table 42 | ) 43 | 44 | 45 | def vectors_data_from_points_data( 46 | point_coordinates: np.ndarray, features_table: pd.DataFrame 47 | ) -> Tuple[np.ndarray, np.ndarray]: 48 | """Get the vectors data for normal and up vectors from an oriented points layer. 49 | 50 | Parameters 51 | ---------- 52 | point_coordinates : np.ndarray 53 | (n, 3) array of the coordinates of each point. 54 | features_table : pd.DataFrame 55 | The layer features table. 56 | 57 | Returns 58 | ------- 59 | normals_data : np.ndarray 60 | The Vectors layer data for the normal vectors 61 | up_data : np.ndarray 62 | The Vectors layer data for the up vectors 63 | """ 64 | # check in the points layer has any data 65 | if len(point_coordinates) == 0: 66 | # if there are no points, there are no vectors 67 | return np.empty((0, 2, 3)), np.empty((0, 2, 3)) 68 | # get the vectors 69 | normal_vectors = features_table[ 70 | [NAPARI_NORMAL_0, NAPARI_NORMAL_1, NAPARI_NORMAL_2] 71 | ].to_numpy() 72 | up_vectors = features_table[ 73 | [NAPARI_UP_0, NAPARI_UP_1, NAPARI_UP_2] 74 | ].to_numpy() 75 | rotations = features_table[ROTATION].to_numpy() 76 | rotated_up_vectors = rotate_around_vector( 77 | rotate_around=normal_vectors, to_rotate=up_vectors, angle=rotations 78 | ) 79 | 80 | # make the vector data 81 | n_points = point_coordinates.shape[0] 82 | normal_vector_data = np.zeros((n_points, 2, 3)) 83 | normal_vector_data[:, 0, :] = point_coordinates 84 | normal_vector_data[:, 1, :] = normal_vectors 85 | 86 | up_vector_data = np.zeros((n_points, 2, 3)) 87 | up_vector_data[:, 0, :] = point_coordinates 88 | up_vector_data[:, 1, :] = rotated_up_vectors 89 | 90 | return normal_vector_data, up_vector_data 91 | 92 | 93 | def get_vectors_data_from_points_layer( 94 | points_layer: Points, point_index: Union[int, List[int]] 95 | ) -> Tuple[np.ndarray, np.ndarray, Union[float, np.ndarray]]: 96 | """Get the point vectors from the points features table. 97 | 98 | Parameters 99 | ---------- 100 | points_layer : Points 101 | The points layer to get the vectors from. 102 | point_index : Union[int, List[int]] 103 | The indices of the points to get the vectors for. 104 | 105 | Returns 106 | ------- 107 | selected_normal_vectors : np.ndarray 108 | The normal vectors from the selected points. 109 | selected_up_vectors : np.ndarray 110 | The up-vectors from the selected points. 111 | selected_rotations : np.ndarray 112 | The rotations from the selected points. 113 | """ 114 | feature_table = points_layer.features 115 | 116 | # get the normal vector 117 | normal_vectors = feature_table[ 118 | [NAPARI_NORMAL_0, NAPARI_NORMAL_1, NAPARI_NORMAL_2] 119 | ].to_numpy() 120 | selected_normal_vector = normal_vectors[point_index] 121 | 122 | # get the up vector 123 | up_vectors = feature_table[ 124 | [NAPARI_UP_0, NAPARI_UP_1, NAPARI_UP_2] 125 | ].to_numpy() 126 | selected_up_vector = up_vectors[point_index] 127 | 128 | # get the rotation 129 | rotations = feature_table[ROTATION].to_numpy() 130 | selected_rotations = rotations[point_index] 131 | 132 | return selected_normal_vector, selected_up_vector, selected_rotations 133 | 134 | 135 | def update_vectors_data_on_points_layer( 136 | points_layer: Points, 137 | point_index: Union[int, List[int]], 138 | normal_vectors: np.ndarray, 139 | up_vectors: np.ndarray, 140 | rotations: np.ndarray, 141 | ): 142 | """Update all fields in the vectors data on a points layer. 143 | 144 | Parameters 145 | ---------- 146 | points_layer : Points 147 | The points layer to be updated. 148 | point_index : Union[int, List[int]] 149 | The indices of the points to be updated. 150 | normal_vectors : np.ndarray 151 | (n, 3) array of the new normal vectors. 152 | up_vectors : np.ndarray 153 | (n, 3) array of the new up vectors. 154 | rotations : np.ndarray 155 | (n,) array of the new rotation angles in radians. 156 | """ 157 | if isinstance(point_index, int): 158 | point_index = [point_index] 159 | 160 | feature_table = points_layer.features 161 | 162 | # get the normal vector 163 | current_normal_vectors = feature_table[ 164 | [NAPARI_NORMAL_0, NAPARI_NORMAL_1, NAPARI_NORMAL_2] 165 | ].to_numpy() 166 | current_normal_vectors[point_index, :] = normal_vectors 167 | 168 | # get the up vector 169 | current_up_vectors = feature_table[ 170 | [NAPARI_UP_0, NAPARI_UP_1, NAPARI_UP_2] 171 | ].to_numpy() 172 | current_up_vectors[point_index, :] = up_vectors 173 | 174 | # get the rotation 175 | current_rotations = feature_table[ROTATION].to_numpy() 176 | current_rotations[point_index] = rotations 177 | 178 | # set the values 179 | feature_table[NAPARI_NORMAL_0] = current_normal_vectors[:, 0] 180 | feature_table[NAPARI_NORMAL_1] = current_normal_vectors[:, 1] 181 | feature_table[NAPARI_NORMAL_2] = current_normal_vectors[:, 2] 182 | feature_table[NAPARI_UP_0] = current_up_vectors[:, 0] 183 | feature_table[NAPARI_UP_1] = current_up_vectors[:, 1] 184 | feature_table[NAPARI_UP_2] = current_up_vectors[:, 2] 185 | feature_table[ROTATION] = current_rotations 186 | 187 | 188 | def update_rotations_on_points_layer( 189 | points_layer: Points, 190 | point_index: Union[int, List[int]], 191 | rotations: np.ndarray, 192 | ) -> None: 193 | """Update the rotations for selected points. 194 | 195 | Parameters 196 | ---------- 197 | points_layer : Points 198 | The points layer to be updated. 199 | point_index : Union[int, List[int]] 200 | The indices of the points to update the rotations on. 201 | rotations : np.ndarray 202 | The new rotations to set. 203 | """ 204 | if isinstance(point_index, int): 205 | point_index = [point_index] 206 | 207 | feature_table = points_layer.features 208 | 209 | # get the rotation 210 | current_rotations = feature_table[ROTATION].to_numpy() 211 | current_rotations[point_index] = rotations 212 | 213 | # set the values 214 | feature_table[ROTATION] = current_rotations 215 | -------------------------------------------------------------------------------- /src/surforama/utils/stats.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import potpourri3d as pp3d 3 | import pyvista as pv 4 | from pygeodesic import geodesic 5 | 6 | from surforama.constants import NAPARI_UP_0, NAPARI_UP_1, NAPARI_UP_2 7 | 8 | 9 | def find_closest_vertex(vertices, point): 10 | """Find the index of the closest vertex to a given point.""" 11 | distances = np.linalg.norm(vertices - point, axis=1) 12 | return np.argmin(distances) 13 | 14 | 15 | def compute_geod_distance_matrix( 16 | verts, faces, point_coordinates, method="exact" 17 | ): 18 | """ 19 | Compute the geodesic distance matrix between a set of points on a mesh. 20 | 21 | Has two methods for computing the geodesic distances: 22 | - "exact": Uses the exact geodesic algorithm from the pygeodesic library. 23 | (takes forever for large meshes and many points) 24 | - "fast": Uses the mesh heat method distance solver from the potpourri3d library. 25 | (faster but less accurate -- but still good enough for most applications; 26 | differences are in the area of ~[-0.2, 0.2] vx for the distances between points on the same mesh) 27 | 28 | Parameters 29 | ---------- 30 | verts : np.ndarray 31 | The vertices of the mesh. 32 | faces : np.ndarray 33 | The faces of the mesh. 34 | point_coordinates : np.ndarray 35 | The coordinates of the points for which the geodesic distances should be computed. 36 | method : str 37 | The method to use for computing the geodesic distances. Can be either "exact" or "fast". 38 | 39 | Returns 40 | ------- 41 | np.ndarray 42 | The geodesic distance matrix between the points. 43 | 44 | """ 45 | 46 | if method == "exact": 47 | geoalg = geodesic.PyGeodesicAlgorithmExact(verts, faces) 48 | elif method == "fast": 49 | solver = pp3d.MeshHeatMethodDistanceSolver(V=verts, F=faces) 50 | 51 | point_idcs = [ 52 | find_closest_vertex(verts, point) for point in point_coordinates 53 | ] 54 | 55 | distance_matrix = np.zeros((len(point_idcs), len(point_idcs))).astype( 56 | np.float32 57 | ) 58 | for i, point_idx in enumerate(point_idcs): 59 | if method == "exact": 60 | distances, _ = geoalg.geodesicDistances( 61 | np.array([point_idx]), np.arange(len(verts)) 62 | ) 63 | elif method == "fast": 64 | distances = solver.compute_distance(point_idx) 65 | 66 | distances[point_idx] = 1e5 67 | distances = distances[point_idcs] 68 | distance_matrix[i] = distances 69 | 70 | return distance_matrix 71 | 72 | 73 | def compute_surface_occupancy( 74 | verts, faces, point_coordinates, only_front=True 75 | ): 76 | """ 77 | Compute the surface occupancy of a set of points on a mesh. 78 | 79 | The surface occupancy is defined as the number of points divided by the surface area 80 | of the mesh that is covered by the points. 81 | 82 | Parameters 83 | ---------- 84 | verts : np.ndarray 85 | The vertices of the mesh. 86 | faces : np.ndarray 87 | The faces of the mesh. 88 | point_coordinates : np.ndarray 89 | The coordinates of the points for which the surface occupancy should be computed. 90 | only_front : bool 91 | If True, only the front side of the mesh is considered for the surface area computation. 92 | This means that the surface area is divided by 2. 93 | 94 | Returns 95 | ------- 96 | float 97 | The surface occupancy of the points on the mesh. 98 | """ 99 | pv_faces = np.hstack([np.ones((faces.shape[0], 1)) * 3, faces]) 100 | pv_faces = pv_faces.flatten().astype(np.int32) 101 | pv_mesh = pv.PolyData(verts, pv_faces) 102 | area = pv_mesh.area 103 | if only_front: 104 | area /= 2.0 # divide by 2 to only take the front side 105 | return len(point_coordinates) / area 106 | 107 | 108 | def orientations_of_knn_inplane( 109 | distance_matrix, feature_table, k=3, c2_symmetry=False 110 | ): 111 | """ 112 | Compute the angular differences between the up vectors of a point and its k nearest neighbors. 113 | 114 | The angular differences are computed in degrees and are in the range [0, 180] if c2_symmetry is False 115 | and in the range [0, 90] if c2_symmetry is True. 116 | 117 | Parameters 118 | ---------- 119 | distance_matrix : np.ndarray 120 | The distance matrix between the points. 121 | feature_table : pd.DataFrame 122 | The feature table containing the up vectors of the points. 123 | k : int 124 | The number of nearest neighbors to consider. 125 | c2_symmetry : bool 126 | Whether to consider the c2 symmetry of the up vectors. 127 | (implies C2 symmetry of the respective protein) 128 | 129 | Returns 130 | ------- 131 | list 132 | A list of angular differences for each point. (in degrees) 133 | """ 134 | up_vectors = feature_table[ 135 | [NAPARI_UP_0, NAPARI_UP_1, NAPARI_UP_2] 136 | ].to_numpy() 137 | nn_orientations = [] 138 | for i in range(distance_matrix.shape[0]): 139 | start_vector = up_vectors[i] 140 | nn_idx = np.argsort(distance_matrix[i])[:k] 141 | cosine_similarities = np.degrees( 142 | np.arccos(np.dot(start_vector, up_vectors[nn_idx].T)) 143 | ) 144 | if c2_symmetry: 145 | cosine_similarities = np.minimum( 146 | cosine_similarities, 180 - cosine_similarities 147 | ) 148 | nn_orientations.append(cosine_similarities) 149 | return np.array(nn_orientations) 150 | -------------------------------------------------------------------------------- /src/surforama/utils/twoD_averages.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import trimesh 3 | from scipy.ndimage import map_coordinates 4 | 5 | from surforama.utils.geometry import find_closest_normal 6 | 7 | 8 | def define_rotation_kernel(shape: tuple) -> np.ndarray: 9 | """ 10 | Define a set of rotation kernels based on a specified shape. 11 | 12 | Parameters 13 | ---------- 14 | shape : tuple 15 | The shape (dimensions) for which the kernel is to be defined. 16 | 17 | Returns 18 | ------- 19 | np.ndarray 20 | A 3D array containing rotation kernels for each radius. 21 | """ 22 | img_center = np.round(0.5 * np.asarray(shape)) - 1 23 | rad_voxels = min( 24 | int(shape[0] - img_center[0]), int(shape[1] - img_center[1]) 25 | ) # number of radius rings 26 | 27 | X, Y = np.meshgrid( 28 | np.arange(shape[0]), np.arange(shape[1]), indexing="ij" 29 | ) # X, Y, meshgrid for x and y coordinates 30 | kernels = np.zeros( 31 | (rad_voxels, shape[0], shape[1]), dtype=float 32 | ) # kernels, rotation kernels 33 | 34 | distances_to_center = np.sqrt( 35 | (X - img_center[0] - 0.5) ** 2 + (Y - img_center[1] - 0.5) ** 2 36 | ) 37 | for i in range(rad_voxels): 38 | rad_ring = ( 39 | np.abs(distances_to_center - i) - 2 40 | ) # ring of radius i with thickness 2 41 | rad_ring[rad_ring > 0] = ( 42 | 0 # set everything further than two voxels from the ring to zero 43 | ) 44 | kernels[i, :, :] = -0.5 * rad_ring 45 | 46 | return kernels 47 | 48 | 49 | def avg_vol_2D(vol: np.ndarray, mirror: bool = False) -> np.ndarray: 50 | """ 51 | Rotationally average an input volume to create a 2D image. 52 | 53 | Parameters 54 | ---------- 55 | vol : np.ndarray 56 | A 3D numpy array representing the volume. 57 | mirror : bool, optional 58 | Flag to mirror the averaged results horizontally. 59 | 60 | Returns 61 | ------- 62 | np.ndarray 63 | A 2D image representing the averaged volume. 64 | """ 65 | shape = vol.shape 66 | kernels = define_rotation_kernel(shape) 67 | 68 | # Average 69 | avg = np.zeros( 70 | (min(vol.shape[0], vol.shape[1]), kernels.shape[0]), dtype=float 71 | ) 72 | 73 | for i in range(vol.shape[2]): 74 | hold = vol[:, :, i] 75 | for j, kernel in enumerate(kernels): 76 | avg[i, j] = (hold * kernel).sum() / (kernel.sum() + 1e-6) 77 | 78 | if mirror: 79 | avg = np.concatenate((avg[:, -1:0:-1], avg), axis=1) 80 | return avg 81 | 82 | 83 | def extract_normal_volume( 84 | point: np.ndarray, normal: np.ndarray, tomogram: np.ndarray, shape: tuple 85 | ) -> np.ndarray: 86 | """ 87 | Extract a volume from a tomogram that is aligned with a point and a normal vector. 88 | 89 | Parameters 90 | ---------- 91 | point : np.ndarray 92 | A 3D point in the tomogram. 93 | normal : np.ndarray 94 | The normal vector. 95 | tomogram : np.ndarray 96 | A 3D numpy array of the tomogram. 97 | shape : tuple 98 | The desired shape of the extracted volume. 99 | 100 | Returns 101 | ------- 102 | np.ndarray 103 | A 3D numpy array representing the extracted volume aligned with the normal vector. 104 | """ 105 | # Normalize the normal vector 106 | z_axis = normal / np.linalg.norm(normal) 107 | 108 | # Arbitrary choice for X-axis (just make sure it's not parallel to Z) 109 | x_axis = ( 110 | np.array([1, 0, 0]) 111 | if z_axis[0] == 0 or z_axis[1] == 0 112 | else np.array([0, 0, 1]) 113 | ) 114 | x_axis = ( 115 | x_axis - np.dot(x_axis, z_axis) * z_axis 116 | ) # Remove the component parallel to Z 117 | x_axis /= np.linalg.norm(x_axis) # Normalize 118 | # Y-axis to complete the right-handed system 119 | y_axis = np.cross(z_axis, x_axis) 120 | 121 | # Create rotation matrix from the original axes to the new axes 122 | rotation_matrix = np.array([x_axis, y_axis, z_axis]) 123 | 124 | # Define the local coordinates around the point 125 | local_x = np.linspace(-shape[0] / 2, shape[0] / 2, shape[0]) 126 | local_y = np.linspace(-shape[1] / 2, shape[1] / 2, shape[1]) 127 | local_z = np.linspace(-shape[2] / 2, shape[2] / 2, shape[2]) 128 | local_grid = np.array( 129 | np.meshgrid(local_x, local_y, local_z, indexing="ij") 130 | ) 131 | 132 | # Flatten and rotate the grid, then add the point 133 | local_grid_flat = local_grid.reshape(3, -1) 134 | global_grid_flat = np.dot(rotation_matrix.T, local_grid_flat).T + point 135 | 136 | # Use scipy's map_coordinates to extract the aligned volume 137 | extracted_volume = map_coordinates( 138 | tomogram, global_grid_flat.T, order=1, mode="nearest" 139 | ).reshape(shape) 140 | 141 | return extracted_volume 142 | 143 | 144 | def create_2D_averages( 145 | positions: list, 146 | mesh: trimesh.Trimesh, 147 | tomogram: np.ndarray, 148 | shape: tuple = (20, 20, 20), 149 | mirror: bool = True, 150 | ) -> np.ndarray: 151 | """ 152 | Create 2D averages from a list of positions using a given mesh and tomogram. 153 | 154 | Parameters 155 | ---------- 156 | positions : list 157 | A list of 3D points. 158 | mesh : 159 | A mesh object used to find normals corresponding to the given positions. 160 | tomogram : np.ndarray 161 | A 3D numpy array containing the tomogram data. 162 | mirror : bool, optional 163 | Flag to mirror the results horizontally for each 2D average. 164 | 165 | Returns 166 | ------- 167 | np.ndarray 168 | An array of 2D averages calculated for each position. 169 | 170 | """ 171 | averages = [] 172 | for position in positions: 173 | normal = find_closest_normal(mesh, position) 174 | volume = extract_normal_volume(position, normal, tomogram, shape) 175 | avg = avg_vol_2D(volume, mirror=mirror) 176 | averages.append(avg) 177 | return np.array(averages) 178 | 179 | 180 | def normalize_averages(avgs: np.ndarray) -> np.ndarray: 181 | """ 182 | Normalize the average images. 183 | 184 | This is done by subtracting the mean and dividing by the standard deviation. 185 | Mean and standard deviation are calculated across all the averages. 186 | 187 | Parameters 188 | ---------- 189 | avgs : np.ndarray 190 | The array of averages to be normalized. 191 | 192 | Returns 193 | ------- 194 | np.ndarray 195 | The normalized averages. 196 | """ 197 | mean = np.mean(avgs) 198 | std = np.std(avgs) 199 | avgs = (avgs - mean) / std 200 | return avgs 201 | -------------------------------------------------------------------------------- /stylesheets/extra.css: -------------------------------------------------------------------------------- 1 | .twitter { 2 | color: #1DA1F2; 3 | } 4 | 5 | .orcid { 6 | color: #A6CE39; 7 | } 8 | 9 | .scholar { 10 | color: #4285F4; 11 | } 12 | 13 | .github { 14 | color: #000000; 15 | } 16 | 17 | .mail { 18 | color: #9fa4b0; 19 | } 20 | 21 | 22 | 23 | * { 24 | box-sizing: border-box; 25 | margin: 0; 26 | padding: 0; 27 | } 28 | 29 | 30 | .heading { 31 | text-align: center; 32 | font-size: 2.0em; 33 | letter-spacing: 1px; 34 | padding: 40px; 35 | color: white; 36 | } 37 | 38 | 39 | .gallery-dl { 40 | padding: 20px; 41 | display: flex; 42 | flex-wrap: wrap; 43 | justify-content: center; 44 | } 45 | 46 | .gallery-dl img { 47 | height: 150px; 48 | width: 130px; 49 | transform: scale(0.7); 50 | transition: transform 0.4s ease; 51 | } 52 | 53 | .dl-box { 54 | box-sizing: content-box; 55 | margin: 10px; 56 | height: 150px; 57 | width: 150px; 58 | overflow: hidden; 59 | display: inline-block; 60 | color: inherit; 61 | position: relative; 62 | background-color: rgba(0, 0, 0, 0); 63 | } 64 | 65 | .dl-caption { 66 | position: absolute; 67 | bottom: 30px; 68 | left: 30px; 69 | opacity: 0.0; 70 | transition: transform 0.3s ease, opacity 0.3s ease; 71 | } 72 | 73 | .dl-caption > p:nth-child(2) { 74 | font-size: 0.8em; 75 | } 76 | 77 | .transparent-box-dl { 78 | height: 150px; 79 | width: 150px; 80 | background-color:rgba(0, 0, 0, 0); 81 | position: absolute; 82 | top: 0; 83 | left: 0; 84 | transition: background-color 0.3s ease; 85 | } 86 | 87 | .dl-box:hover img { 88 | transform: translateY(-160px); 89 | } 90 | 91 | .dl-box:hover .transparent-box { 92 | background-color:rgba(0, 0, 0, 1); 93 | } 94 | 95 | .dl-box:hover .dl-caption { 96 | transform: translateY(-20px); 97 | opacity: 1.0; 98 | } 99 | 100 | .dl-box:hover { 101 | cursor: pointer; 102 | } 103 | 104 | .opacity-low { 105 | opacity: 0.8; 106 | } 107 | 108 | .dl-caption > p:nth-child(2) { 109 | font-size: 0.8em; 110 | } 111 | a.nocolor 112 | { 113 | color: inherit; 114 | } 115 | a.nocolor:hover 116 | { 117 | color: inherit; 118 | } 119 | 120 | video.center { 121 | display: block; 122 | margin-left: auto; 123 | margin-right: auto; 124 | } 125 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # For more information about tox, see https://tox.readthedocs.io/en/latest/ 2 | [tox] 3 | envlist = py{38,39,310}-{linux,macos,windows} 4 | isolated_build=true 5 | 6 | [gh-actions] 7 | python = 8 | 3.8: py38 9 | 3.9: py39 10 | 3.10: py310 11 | 12 | [gh-actions:env] 13 | PLATFORM = 14 | ubuntu-latest: linux 15 | macos-latest: macos 16 | windows-latest: windows 17 | 18 | [testenv] 19 | platform = 20 | macos: darwin 21 | linux: linux 22 | windows: win32 23 | passenv = 24 | CI 25 | GITHUB_ACTIONS 26 | DISPLAY 27 | XAUTHORITY 28 | NUMPY_EXPERIMENTAL_ARRAY_FUNCTION 29 | PYVISTA_OFF_SCREEN 30 | extras = 31 | testing 32 | commands = pytest -v --color=yes --cov=surforama --cov-report=xml 33 | --------------------------------------------------------------------------------