├── tests ├── conftest.py ├── test_bin_ndarray.py ├── test_io.py └── test_items.py ├── .gitignore ├── pyserialem ├── __init__.py ├── utils.py ├── navigation.py ├── reader.py └── montage.py ├── .github └── workflows │ ├── publish.yml │ └── test.yml ├── data ├── nav.nav └── gm.mrc.mdoc ├── LICENCE ├── pyproject.toml ├── .pre-commit-config.yaml ├── setup.py ├── README.md └── demos └── montage_processing_serialem.ipynb /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | 5 | 6 | @pytest.fixture 7 | def drc(): 8 | return Path(__file__).parent 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # general things to ignore 2 | build/ 3 | dist/ 4 | *.egg-info/ 5 | *.egg 6 | *.py[cod] 7 | __pycache__/ 8 | *.so 9 | *~ 10 | 11 | # due to using tox and pytest 12 | .tox 13 | .cache 14 | .coverage 15 | htmlcov 16 | -------------------------------------------------------------------------------- /pyserialem/__init__.py: -------------------------------------------------------------------------------- 1 | from .montage import Montage 2 | from .reader import MapItem 3 | from .reader import NavItem 4 | from .reader import read_mdoc_file 5 | from .reader import read_nav_file 6 | from .reader import stitch_map_items 7 | from .reader import write_nav_file 8 | 9 | __version__ = '0.3.2' 10 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: publish 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | build: 9 | runs-on: windows-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: actions/setup-python@v1 14 | with: 15 | python-version: 3.7 16 | architecture: x64 17 | 18 | - name: Install with poetry 19 | run: | 20 | pip install poetry==1.0.5 21 | 22 | - name: Build with poetry 23 | run: poetry build 24 | 25 | - name: Publish with poetry 26 | run: | 27 | poetry publish -u ${{ secrets.pypi_user }} -p ${{ secrets.pypi_pw }} 28 | -------------------------------------------------------------------------------- /data/nav.nav: -------------------------------------------------------------------------------- 1 | AdocVersion = 2.00 2 | LastSavedAs = nav.nav 3 | 4 | [Item = 17-1-A] 5 | Color = 2 6 | StageXYZ = -495.956 436.348 44.77 7 | NumPts = 5 8 | Regis = 1 9 | Type = 2 10 | Note = Sec 0 - map.mrc - 11 | BklshXY = -10 -10 12 | RawStageXY = -495.956 436.348 13 | MapFile = map.mrc 14 | MapID = 1291353952 15 | MapMontage = 0 16 | MapSection = 0 17 | MapBinning = 1 18 | MapMagInd = 14 19 | MapCamera = 0 20 | MapScaleMat = 0.638997 -26.616 -26.5862 -1.01529 21 | MapWidthHeight = 4096 4096 22 | MapMinMaxScale = 68 4107.5 23 | MapFramesXY = 0 0 24 | MontBinning = 1 25 | MapExposure = 0.25 26 | MapSettling = 0 27 | ShutterMode = 0 28 | MapSpotSize = 3 29 | MapIntensity = 0.638224 30 | MapSlitIn = 0 31 | MapSlitWidth = 0 32 | ImageType = 0 33 | MontUseStage = -1 34 | MapProbeMode = 1 35 | MapLDConSet = -1 36 | MapTiltAngle = -0.1 37 | PtsX = -421.93 -416.058 -569.982 -575.854 -421.93 38 | PtsY = 515.071 361.32 357.625 511.376 515.071 39 | -------------------------------------------------------------------------------- /tests/test_bin_ndarray.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | 4 | from pyserialem.utils import bin_ndarray 5 | 6 | 7 | def test_binning(): 8 | x = 32 9 | y = 32 10 | x2 = int(x / 2) 11 | y2 = int(y / 2) 12 | a = np.arange(x * y).reshape(x, y) 13 | 14 | b = bin_ndarray(a, new_shape=(x2, y2)) 15 | assert b.shape == (x2, y2) 16 | 17 | b = bin_ndarray(a, new_shape=(x, y)) 18 | assert b.shape == (x, y) 19 | 20 | b = bin_ndarray(a, binning=2) 21 | assert b.shape == (x2, y2) 22 | 23 | with pytest.raises(ValueError): 24 | b = bin_ndarray(a, binning=1.32) 25 | 26 | 27 | def test_value(): 28 | m = np.arange(0, 100, 1).reshape((10, 10)) 29 | n = bin_ndarray(m, new_shape=(5, 5), operation='sum') 30 | m = np.array([ 31 | [22, 30, 38, 46, 54], 32 | [102, 110, 118, 126, 134], 33 | [182, 190, 198, 206, 214], 34 | [262, 270, 278, 286, 294], 35 | [342, 350, 358, 366, 374], 36 | ]) 37 | assert np.allclose(n, m) 38 | -------------------------------------------------------------------------------- /tests/test_io.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | 4 | import pyserialem as pysem 5 | 6 | 7 | def test_reader_writer(drc): 8 | fn = drc.parent / 'data' / 'nav.nav' 9 | 10 | items = pysem.read_nav_file(fn) 11 | 12 | assert isinstance(items, list) 13 | assert len(items) == 1 14 | 15 | out = drc.parent / 'data' / 'out.nav' 16 | 17 | pysem.write_nav_file(out, *items) 18 | 19 | assert out.exists() 20 | 21 | pysem.read_nav_file(out) 22 | 23 | assert isinstance(items, list) 24 | assert len(items) == 1 25 | 26 | 27 | def test_mdoc_reader(drc): 28 | fn = drc.parent / 'data' / 'gm.mrc.mdoc' 29 | 30 | items = pysem.read_mdoc_file(fn) 31 | 32 | assert len(items) == 26 33 | assert isinstance(items[0], dict) 34 | 35 | kind = 'ZValue' 36 | items = pysem.read_mdoc_file(fn, only_kind=kind) 37 | assert len(items) == 25 38 | assert items[0]['kind'] == kind 39 | 40 | kind = 'MontSection' 41 | items = pysem.read_mdoc_file(fn, only_kind=kind) 42 | assert len(items) == 1 43 | assert items[0]['kind'] == kind 44 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | runs-on: windows-latest 12 | 13 | strategy: 14 | matrix: 15 | python-version: [3.6, 3.7, 3.8, 3.9] 16 | 17 | steps: 18 | - uses: actions/checkout@v1 19 | - name: Set up Python ${{ matrix.python-version }} 20 | uses: actions/setup-python@v1 21 | with: 22 | python-version: ${{ matrix.python-version }} 23 | architecture: x64 24 | 25 | - name: Lint with flake8 26 | run: | 27 | pip install flake8 28 | # stop the build if there are Python syntax errors or undefined names 29 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 30 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 31 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics --ignore E501 32 | 33 | - name: Install with poetry 34 | run: | 35 | pip install poetry==1.0.5 36 | # poetry install --no-dev 37 | poetry install 38 | 39 | - name: Test with pytest 40 | run: | 41 | poetry run pytest tests 42 | 43 | - name: Build with poetry 44 | run: poetry build 45 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | Copyright 2020 Stef Smeets 2 | 3 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 4 | 5 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 6 | 7 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 8 | 9 | 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 10 | 11 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 12 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool] 2 | [tool.poetry] 3 | name = "pyserialem" 4 | version = "0.3.2" 5 | description = "Python module to read/write SerialEM .nav files." 6 | keywords = [ 7 | "serialem", 8 | "electron-microscopy", 9 | "navigator", 10 | ] 11 | classifiers = [ 12 | "Development Status :: 4 - Beta", 13 | "Intended Audience :: Developers", 14 | "License :: OSI Approved :: BSD License", 15 | "Topic :: Software Development :: Libraries", 16 | "Programming Language :: Python :: 3", 17 | "Programming Language :: Python :: 3.6", 18 | "Programming Language :: Python :: 3.7", 19 | "Programming Language :: Python :: 3.8", 20 | "Programming Language :: Python :: 3.9", 21 | ] 22 | homepage = "http://github.com/instamatic-dev/pyserialem" 23 | repository = "http://github.com/instamatic-dev/pyserialem" 24 | documentation = "http://github.com/instamatic-dev/pyserialem" 25 | maintainers = ["Stef Smeets "] 26 | authors = ["Stef Smeets "] 27 | readme = "README.md" 28 | license = "BSD-3-clause" 29 | include = [ 30 | ".pre-commit-yaml", 31 | "LICENCE", 32 | "setup.py", 33 | "pyserialem/*.py", 34 | "data/nav.nav", 35 | "data/gm.mrc.mdoc", 36 | "tests/*.py", 37 | ] 38 | 39 | [tool.poetry.dependencies] 40 | python = ">=3.6.1" 41 | matplotlib = ">=3.1.2" 42 | numpy = ">=1.17.3" 43 | mrcfile = ">=1.1.2" 44 | lmfit = ">=1.0.1" 45 | scikit-image = ">=0.17.2" 46 | tqdm = ">=4.46.1" 47 | scipy = ">=1.5.0" 48 | 49 | 50 | [tool.poetry.dev-dependencies] 51 | check-manifest = "*" 52 | pre-commit = "*" 53 | pytest = ">=5.4.1" 54 | pytest-cov = ">=2.8.1" 55 | 56 | [tool.poetry.urls] 57 | "Bug Reports" = "https://github.com/instamatic-dev/pyserialem/issues" 58 | 59 | [tool.dephell.main] 60 | from = {format = "poetry", path = "pyproject.toml"} 61 | to = {format = "setuppy", path = "setup.py"} 62 | versioning = "semver" 63 | 64 | [build-system] 65 | requires = ["poetry"] 66 | build-backend = "poetry.masonry.api" 67 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v2.4.0 6 | hooks: 7 | - id: trailing-whitespace 8 | args: [--markdown-linebreak-ext=md] 9 | - id: end-of-file-fixer 10 | - id: check-yaml 11 | - id: check-docstring-first 12 | - id: check-builtin-literals 13 | - id: check-ast 14 | - id: check-merge-conflict 15 | - id: debug-statements 16 | - id: double-quote-string-fixer 17 | # - id: check-json 18 | # - id: check-added-large-files 19 | - repo: https://github.com/myint/docformatter 20 | rev: v1.3.1 21 | hooks: 22 | - id: docformatter 23 | - repo: https://github.com/asottile/reorder_python_imports 24 | rev: v1.8.0 25 | hooks: 26 | - id: reorder-python-imports 27 | args: [--py3-plus] 28 | - repo: https://github.com/pre-commit/mirrors-autopep8 29 | rev: v1.4.4 # Use the sha / tag you want to point at 30 | hooks: 31 | - id: autopep8 32 | args: ['--in-place', '--ignore=E501,W504'] 33 | - repo: https://github.com/asottile/pyupgrade 34 | rev: v1.25.2 35 | hooks: 36 | - id: pyupgrade 37 | args: ['--py36-plus'] 38 | - repo: https://gitlab.com/pycqa/flake8 39 | rev: 3.7.9 40 | hooks: 41 | - id: flake8 42 | args: ['--statistics', '--ignore=F401,F403,F405,F821,F841,E265,E501,A003'] 43 | additional_dependencies: [ 44 | 'flake8-blind-except', 45 | 'flake8-commas', 46 | 'flake8-comprehensions', 47 | 'flake8-deprecated', 48 | 'flake8-mutable', 49 | 'flake8-quotes', 50 | 'flake8-tidy-imports', 51 | 'flake8-type-annotations', 52 | 'flake8-builtins', 53 | ] 54 | - repo: https://github.com/kynan/nbstripout 55 | rev: master 56 | hooks: 57 | - id: nbstripout 58 | files: ".ipynb" 59 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # DO NOT EDIT THIS FILE! 2 | # This file has been autogenerated by dephell <3 3 | # https://github.com/dephell/dephell 4 | 5 | try: 6 | from setuptools import setup 7 | except ImportError: 8 | from distutils.core import setup 9 | 10 | 11 | import os.path 12 | 13 | readme = '' 14 | here = os.path.abspath(os.path.dirname(__file__)) 15 | readme_path = os.path.join(here, 'README.rst') 16 | if os.path.exists(readme_path): 17 | with open(readme_path, 'rb') as stream: 18 | readme = stream.read().decode('utf8') 19 | 20 | 21 | setup( 22 | long_description=readme, 23 | name='pyserialem', 24 | version='0.3.2', 25 | description='Python module to read/write SerialEM .nav files.', 26 | python_requires='>=3.6.1', 27 | project_urls={ 28 | 'documentation': 'http://github.com/instamatic-dev/pyserialem', 29 | 'homepage': 'http://github.com/instamatic-dev/pyserialem', 30 | 'repository': 'http://github.com/instamatic-dev/pyserialem'}, 31 | author='Stef Smeets', 32 | author_email='s.smeets@esciencecenter.nl', 33 | license='BSD-3-clause', 34 | keywords='serialem electron-microscopy navigator', 35 | classifiers=[ 36 | 'Development Status :: 4 - Beta', 37 | 'Intended Audience :: Developers', 38 | 'License :: OSI Approved :: BSD License', 39 | 'Topic :: Software Development :: Libraries', 40 | 'Programming Language :: Python :: 3', 41 | 'Programming Language :: Python :: 3.6', 42 | 'Programming Language :: Python :: 3.7', 43 | 'Programming Language :: Python :: 3.8', 44 | 'Programming Language :: Python :: 3.9'], 45 | packages=['pyserialem'], 46 | package_dir={ 47 | '': '.'}, 48 | package_data={}, 49 | install_requires=[ 50 | 'lmfit>=1.0.1', 51 | 'matplotlib>=3.1.2', 52 | 'mrcfile>=1.1.2', 53 | 'numpy>=1.17.3', 54 | 'scikit-image>=0.17.2', 55 | 'scipy>=1.5.0', 56 | 'tqdm>=4.46.1'], 57 | extras_require={ 58 | 'dev': [ 59 | 'check-manifest', 60 | 'pre-commit', 61 | 'pytest==5.*,>=5.4.1', 62 | 'pytest-cov==2.*,>=2.8.1']}, 63 | ) 64 | -------------------------------------------------------------------------------- /pyserialem/utils.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import numpy as np 4 | import tifffile 5 | import yaml 6 | from numpy.fft import fft2 7 | from numpy.fft import ifft2 8 | 9 | 10 | def bin_ndarray(ndarray, new_shape=None, binning=1, operation='mean'): 11 | """Bins an ndarray in all axes based on the target shape, by summing or 12 | averaging. If no target shape is given, calculate the target shape by the 13 | given binning. 14 | 15 | Number of output dimensions must match number of input dimensions and 16 | new axes must divide old ones. 17 | 18 | Example 19 | ------- 20 | >>> m = np.arange(0,100,1).reshape((10,10)) 21 | >>> n = bin_ndarray(m, new_shape=(5,5), operation='sum') 22 | >>> print(n) 23 | 24 | [[ 22 30 38 46 54] 25 | [102 110 118 126 134] 26 | [182 190 198 206 214] 27 | [262 270 278 286 294] 28 | [342 350 358 366 374]] 29 | """ 30 | if not new_shape: 31 | shape_x, shape_y = ndarray.shape 32 | new_shape = int(shape_x / binning), int(shape_y / binning) 33 | 34 | if new_shape == ndarray.shape: 35 | return ndarray 36 | 37 | operation = operation.lower() 38 | if operation not in ['sum', 'mean']: 39 | raise ValueError('Operation not supported.') 40 | if ndarray.ndim != len(new_shape): 41 | raise ValueError(f'Shape mismatch: {ndarray.shape} -> {new_shape}') 42 | compression_pairs = [(d, c // d) for d, c in zip(new_shape, 43 | ndarray.shape)] 44 | flattened = [l for p in compression_pairs for l in p] 45 | ndarray = ndarray.reshape(flattened) 46 | for i in range(len(new_shape)): 47 | op = getattr(ndarray, operation) 48 | ndarray = op(-1 * (i + 1)) 49 | return ndarray 50 | 51 | 52 | def translation(im0, 53 | im1, 54 | limit_shift: bool = False, 55 | return_fft: bool = False, 56 | ): 57 | """Return translation vector to register images. 58 | 59 | Parameters 60 | ---------- 61 | im0, im1 : np.array 62 | The two images to compare 63 | limit_shift : bool 64 | Limit the maximum shift to the minimum array length or width. 65 | return_fft : bool 66 | Whether to additionally return the cross correlation array between the 2 images 67 | 68 | Returns 69 | ------- 70 | shift: list 71 | Return the 2 coordinates defining the determined image shift 72 | """ 73 | f0 = fft2(im0) 74 | f1 = fft2(im1) 75 | ir = abs(ifft2((f0 * f1.conjugate()) / (abs(f0) * abs(f1)))) 76 | shape = ir.shape 77 | 78 | if limit_shift: 79 | min_shape = min(shape) 80 | shift = int(min_shape / 2) 81 | ir2 = np.roll(ir, (shift, shift), (0, 1)) 82 | ir2 = ir2[:min_shape, :min_shape] 83 | t0, t1 = np.unravel_index(np.argmax(ir2), ir2.shape) 84 | t0 -= shift 85 | t1 -= shift 86 | else: 87 | t0, t1 = np.unravel_index(np.argmax(ir), shape) 88 | if t0 > shape[0] // 2: 89 | t0 -= shape[0] 90 | if t1 > shape[1] // 2: 91 | t1 -= shape[1] 92 | 93 | if return_fft: 94 | return [t0, t1], ir 95 | else: 96 | return [t0, t1] 97 | -------------------------------------------------------------------------------- /tests/test_items.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | 4 | import pyserialem as pysem 5 | 6 | 7 | @pytest.fixture 8 | def map_item(drc): 9 | fn = drc.parent / 'data' / 'nav.nav' 10 | items = pysem.read_nav_file(fn) 11 | map_item = items[0] 12 | assert map_item.kind == 'Map' 13 | return map_item 14 | 15 | 16 | def test_coordinate_convert(map_item): 17 | assert map_item.stagematrix.shape == (2, 2) 18 | 19 | xy = (1, 1) 20 | 21 | new_xy = map_item.pixel_to_stagecoords(xy) 22 | 23 | assert len(new_xy) == 2 24 | 25 | old_xy = map_item.stage_to_pixelcoords(new_xy) 26 | 27 | assert len(new_xy) == 2 28 | assert np.allclose(xy, old_xy) 29 | 30 | 31 | @pytest.mark.skip(reason='No access to data') 32 | def test_load_image(map_item, drc): 33 | img = map_item.load_image(drc.parent / 'data') 34 | 35 | assert isinstance(img, np.ndarray) 36 | assert img.shape == tuple(map_item.MapWidthHeight) 37 | 38 | 39 | def test_add_markers(map_item): 40 | n = 10 41 | 42 | coords = np.arange(n * 2).reshape(n, 2) 43 | nav_items = map_item.add_marker_group(coords) 44 | 45 | assert len(nav_items) == n 46 | 47 | pixelcoords = map_item.markers_as_pixel_coordinates() 48 | assert pixelcoords.shape == (n, 2) 49 | assert np.allclose(pixelcoords, coords) 50 | 51 | stagecoords = map_item.markers_as_stage_coordinates() 52 | assert stagecoords.shape == (n, 2) 53 | 54 | nav_item = nav_items[0] 55 | assert nav_item.kind == 'Marker' 56 | 57 | map_item.update_markers(nav_item) 58 | 59 | assert len(map_item.markers) == n 60 | 61 | map_item.set_markers(nav_item) 62 | 63 | assert len(map_item.markers) == 1 64 | 65 | 66 | def test_to_from_dict(map_item): 67 | d = map_item.to_dict() 68 | 69 | new_map_item = pysem.MapItem.from_dict(d) 70 | 71 | e = new_map_item.to_dict() 72 | 73 | assert isinstance(e, dict) 74 | 75 | # cycle twice to get rid of rounding errors 76 | new_map_item = pysem.MapItem.from_dict(e) 77 | 78 | f = new_map_item.to_dict() 79 | 80 | assert f == e 81 | 82 | 83 | def test_validate(map_item): 84 | map_item.validate() 85 | 86 | del map_item.StageXYZ 87 | 88 | with pytest.raises(KeyError): 89 | map_item.validate() 90 | 91 | 92 | def test_tag_loading(map_item, drc): 93 | n = 10 94 | 95 | coords = np.arange(n * 2).reshape(n, 2) 96 | nav_items = map_item.add_marker_group(coords, acquire=True) 97 | 98 | out = drc.parent / 'data' / 'out2.nav' 99 | pysem.write_nav_file(out, map_item, *nav_items) 100 | 101 | items = pysem.read_nav_file(out, acquire_only=False) 102 | assert len(items) == 1 + n 103 | 104 | items = pysem.read_nav_file(out, acquire_only=True) 105 | assert len(items) == n 106 | 107 | 108 | def test_nav_item(map_item): 109 | coords = np.arange(2).reshape(1, 2) 110 | nav_items = map_item.add_marker_group(coords) 111 | nav_item = nav_items[0] 112 | 113 | assert isinstance(nav_item.stage_x, float) 114 | assert isinstance(nav_item.stage_y, float) 115 | assert isinstance(nav_item.stage_z, float) 116 | assert len(nav_item.color_rgba) == 4 117 | 118 | assert nav_item.color_str == 'red' 119 | 120 | d = nav_item.to_dict() 121 | 122 | new_nav_item = pysem.NavItem(d, tag='test') 123 | assert new_nav_item.tag == 'test' 124 | -------------------------------------------------------------------------------- /pyserialem/navigation.py: -------------------------------------------------------------------------------- 1 | import matplotlib.pyplot as plt 2 | import numpy as np 3 | 4 | 5 | def closest_distance(node, nodes: list) -> np.array: 6 | """Get shortest between a node and a list of nodes (that includes the given 7 | node)""" 8 | nodes = np.asarray(nodes) 9 | dist_2 = np.linalg.norm(nodes - node, axis=1) 10 | return np.sort(dist_2)[1] 11 | 12 | 13 | def filter_nav_items_by_proximity(items, min_sep: float = 5.0) -> np.array: 14 | """Filter navigator items (markers) if they are within `min_sep` micrometer 15 | of another one.""" 16 | ret = [] 17 | stagecoords = np.array([item.stage_xy for item in items]) 18 | for i, coord in enumerate(stagecoords): 19 | try: 20 | min_dist = closest_distance(coord, stagecoords) 21 | except IndexError: 22 | min_dist = np.inf 23 | 24 | if min_dist > min_sep: 25 | ret.append(items[i]) 26 | 27 | return ret 28 | 29 | 30 | def calc_total_dist(coords: list, route: list = None) -> float: 31 | """Calculate the total distance over a list of (x,y) coordinates. 32 | 33 | route, list[int] 34 | List of integers with the same length as `coords` that specifies 35 | the order of the items 36 | 37 | Returns: 38 | total_dist, float 39 | Total distance for the given path 40 | """ 41 | if route is None: 42 | diffs = np.diff(coords, axis=0) 43 | else: 44 | diffs = np.diff(coords[route], axis=0) 45 | 46 | # TODO: add backlash in diffs/total_dist calculation 47 | total_dist = np.linalg.norm(diffs, axis=1).sum() 48 | 49 | return total_dist 50 | 51 | 52 | def two_opt_swap(r: list, i: int, k: int) -> list: 53 | """Reverses items `i`:`k` in the list `r` 54 | https://en.wikipedia.org/wiki/2-opt.""" 55 | out = r.copy() 56 | out[i:k + 1] = out[k:i - 1:-1] 57 | return out 58 | 59 | 60 | def two_opt(coords: list, threshold: float, verbose: bool = False) -> list: 61 | """Implementation of the two_opt algorithm for finding the shortest path 62 | through a list of coordinates (x, y) 63 | https://en.wikipedia.org/wiki/2-opt.""" 64 | route = np.arange(len(coords)) 65 | improvement = 1 66 | initial_distance = best_distance = calc_total_dist(coords, route) 67 | while improvement > threshold: 68 | previous_best_distance = best_distance 69 | for i in range(1, len(route) - 2): 70 | for k in range(i + 1, len(route)): 71 | new_route = two_opt_swap(route, i, k) 72 | new_distance = calc_total_dist(coords, new_route) 73 | if new_distance < best_distance: 74 | route = new_route 75 | best_distance = new_distance 76 | improvement = 1 - best_distance / previous_best_distance 77 | if verbose: 78 | diff = initial_distance - best_distance 79 | perc = diff / initial_distance 80 | print(f'Optimized path for {len(coords)} items from {initial_distance/1000:.1f} to {best_distance/1000:.1f} μm (-{perc:.2%})') 81 | 82 | return route 83 | 84 | 85 | def sort_nav_items_by_shortest_path(items: list, 86 | first: int = 0, 87 | threshold: float = 0.1, 88 | plot: bool = False, 89 | ) -> list: 90 | """Find shortest route based on stage coordinates (.stage_xy) 91 | 92 | Parameters 93 | ---------- 94 | items : list 95 | List of navigation items with stage coordinates (item.stage_xy) 96 | first : int or tuple 97 | If first is an integer, it is the index of the object to start from, must be 0 <= x < len(items) 98 | If it is a tuple, it should contain the stage coordinates, e.g. the last known coordinate, the function will figure out 99 | the closest coordinate to start from. 100 | threshold : float 101 | Number between 0.0 and 1.0 that determines when convergence is reached. If the improvement is smaller than the threshold, the algorithm will accept convergence. 102 | plot : bool 103 | Plot the resulting Path 104 | 105 | Returns 106 | ------- 107 | List of navigation items sorted to minimize total path distance 108 | """ 109 | try: 110 | coords = np.array([item.stage_xy for item in items]) 111 | is_nav = True 112 | except AttributeError: 113 | coords = items 114 | is_nav = False 115 | 116 | if not isinstance(first, int): # assume it is a coordinate 117 | first = np.array(first) 118 | first = np.argmin(np.linalg.norm(coords - first, axis=1)) 119 | print(f'First = {first}') 120 | 121 | if first > 0: 122 | coords = np.concatenate((coords[first:first + 1], coords[0:first], coords[first + 1:])) 123 | 124 | route = two_opt(coords, threshold, verbose=True) 125 | 126 | if plot: 127 | fig, (ax0, ax1) = plt.subplots(ncols=2, figsize=(12, 6)) 128 | new_coords = coords[route] 129 | ax0.set_title(f'Before, total distance: {calc_total_dist(coords)/1000:.3g} μm') 130 | ax0.plot(coords[:, 0] / 1000, coords[:, 1] / 1000, 'r--', marker='.', lw=1.0) 131 | ax0.scatter(coords[0, 0] / 1000, coords[0, 1] / 1000, color='red', s=100) 132 | ax0.axis('equal') 133 | ax1.set_title(f'After, total distance: {calc_total_dist(new_coords)/1000:.3g} μm') 134 | ax1.plot(new_coords[:, 0] / 1000, new_coords[:, 1] / 1000, 'r--', marker='.', lw=1.0) 135 | ax1.scatter(new_coords[0, 0] / 1000, new_coords[0, 1] / 1000, color='red', s=50) 136 | ax1.axis('equal') 137 | plt.show() 138 | 139 | if is_nav: 140 | return [items[i] for i in route] 141 | else: 142 | return items[route] 143 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![GitHub Workflow Status](https://img.shields.io/github/workflow/status/instamatic-dev/pyserialem/build)](https://github.com/instamatic-dev/pyserialem/actions) 2 | [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/pyserialem)](https://pypi.org/project/pyserialem/) 3 | [![PyPI](https://img.shields.io/pypi/v/pyserialem.svg?style=flat)](https://pypi.org/project/pyserialem/) 4 | [![PyPI - Downloads](https://img.shields.io/pypi/dm/pyserialem)](https://pypi.org/project/pyserialem/) 5 | 6 | # PySerialEM 7 | 8 | A small Python library to read and write [SerialEM][serialem] navigator files (`.nav`), and process grid maps. 9 | 10 | Install using `pip install pyserialem`. 11 | 12 | # Usage 13 | 14 | The main use of `pyserialem` is to manipulate coordinates in a `.nav` file written by SerialEM ([specification][serialem_nav]). 15 | 16 | Reading a `.nav` file: 17 | 18 | ```python 19 | import pyserialem 20 | from pathlib import Path 21 | 22 | p = Path('C:/path/to/data/') / 'nav.nav' 23 | items = pyserialem.read_nav_file(p) # list 24 | ``` 25 | You can set the `acquire_only` toggle to return only the items with the `Acquire` tag set: 26 | 27 | ```python 28 | items = pyserialem.read_nav_file(p, acquire_only=True) # list 29 | ``` 30 | 31 | This returns a `list` of `MapItem` and `NavItem`. A `MapItem` is associated with an image in the corresponding `.mrc` file, and a `NavItem` is a marker or point on that image. 32 | 33 | ```python 34 | map_items = [item.kind == 'Map' for item in items] 35 | nav_items = [item.kind == 'Marker' for item in items] 36 | ``` 37 | 38 | All of the tags associated with the `MapItem` or `NavItem` can be accessed as an attribute using the same name as in the `.nav` file, i.e. with the key defined [here][serialem_nav]. This is also how the values should be updated: 39 | 40 | ```python 41 | nav_item = nav_items[0] 42 | stage_position = nav_item.StageXYZ # tuple 43 | map_item.StageXYZ = (100, 200, 0) # overwrite values 44 | ``` 45 | 46 | Alternatively, the stage position can be accessed directly through: 47 | 48 | ```python 49 | x = map_item.stage_x 50 | y = map_item.stage_y 51 | z = map_item.stage_z 52 | xy = map_item.stage_xy 53 | ``` 54 | 55 | ## Map items 56 | 57 | A `MapItem` has all the functions of a `NavItem`, and then some. Each `MapItem` can have a list of markers associated with it: 58 | 59 | ```python 60 | map_item = map_items[0] 61 | markers = map_item.markers # list 62 | ``` 63 | 64 | To visualize them, call: 65 | 66 | ```python 67 | map_item.plot() 68 | ``` 69 | 70 | To just load the image associated with the `MapItem`: 71 | 72 | ```python 73 | img = map_item.load() # np.array 74 | ``` 75 | 76 | They can be extracted as a dictionary: 77 | 78 | ```python 79 | d = map_item.to_dict() # dict 80 | ``` 81 | 82 | ...and restored: 83 | 84 | ```python 85 | new_map_item = pysem.from_dict(d, tag='new_mapitem') 86 | ``` 87 | 88 | This is also the easiest way of constructing a new `MapItem`, because some keys can be autogenerated. Otherwise, all the required keys have to be specified to the `MapItem` constructor. The `tag` specifies the name of the item when displayed in `SerialEM`. If omitted, one will be generated. 89 | 90 | It is easy to add new markers to a `MapItem`. As a pixel coordinate (i.e. from segmentation) is the default. `PySerialEM` calculates the corresponding stage position. The `acquire` argument sets the acquire flag (default=`True`): 91 | 92 | ```python 93 | pixel_position = (0, 0) 94 | new_nav_item = map_item.add_marker( 95 | pixel_position, 96 | tag='pixel_item', 97 | acquire=True) # NavItem 98 | ``` 99 | 100 | You can also add a marker as a stage coordinate (although this is a bit more tricky to calculate the corresponding pixel coordinate): 101 | 102 | ```python 103 | stage_positionion = (1000, 1000) 104 | new_nav_item = map_item.add_marker( 105 | pixel_position, 106 | kind='stage', 107 | tag='stage_item', 108 | acquire=False) # NavItem 109 | ``` 110 | 111 | To add many markers: 112 | 113 | ```python 114 | pixel_coordinates = ((0, 0), (100, 100), (200, 200)) 115 | nav_item_group = map_item.add_marker_group(pixel_coordinates) # tuple 116 | ``` 117 | 118 | Specify `replace=True` to replace the current list of markers associated with the `MapItem`. 119 | 120 | If the `MapItem` has a set of markers associated with it `map_item.markers`, the coordinates be retrieved as followed: 121 | 122 | ```python 123 | map_item.markers_as_pixel_coordinates() # np.array (Nx2) 124 | map_item.markers_as_stage_coordinates() # np.array (Nx2) 125 | ``` 126 | 127 | To just convert between stage and pixel coordinates: 128 | 129 | ```python 130 | pixel_coord = (1024, 1024) 131 | stage_coord = map_item.pixel_to_stagecoords(pixel_coord) # tuple 132 | new_pixel_coord = map_item.stage_to_pixelcoords(stagecoord) # tuple 133 | assert new_pixel_coord == pixel_coord 134 | ``` 135 | 136 | To write a new file: 137 | 138 | ```python 139 | pyserialem.write_nav_file('out.nav', map_item, *nav_item_group) 140 | ``` 141 | 142 | Note the `*`. This function captures arguments in a list (`*args`, so they must be unpacked when supplied. 143 | 144 | ## Stitching 145 | 146 | A basic stitching algorithm is available to get an overview of the location of all map items: 147 | 148 | ```python 149 | map_items = [item for item in items if item.kind == 'Map'] 150 | pyserialem.stitch_map_items(map_items) 151 | ``` 152 | 153 | For more advanced stitching and montaging, use the `pyserialem.montage` module. A [demo notebook](demos/montage_processing_serialem.ipynb) is available to demonstrate its usage. 154 | 155 | ## Mdoc files 156 | 157 | There is also a simple function to read `.mdoc` files ([link][serialem_nav]). This returns a list of python objects where each key can be accessed as an attribute. 158 | 159 | ```python 160 | p = Path('C:/path/to/data') / 'gm.mrc.mdoc' 161 | mdoc = pyserialem.read_mdoc_file(p) 162 | ``` 163 | 164 | [src]: https://github.com/instamatic-dev/pyserialem 165 | [serialem]: https://bio3d.colorado.edu/SerialEM/ 166 | [serialem_nav]: https://bio3d.colorado.edu/SerialEM/hlp/html/about_formats.htm 167 | -------------------------------------------------------------------------------- /demos/montage_processing_serialem.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": null, 6 | "metadata": {}, 7 | "outputs": [], 8 | "source": [ 9 | "%load_ext autoreload\n", 10 | "%autoreload 2" 11 | ] 12 | }, 13 | { 14 | "cell_type": "markdown", 15 | "metadata": {}, 16 | "source": [ 17 | "# PySerialEM - montaging\n", 18 | "\n", 19 | "https://github.com/instamatic-dev/pyserialem\n", 20 | "\n", 21 | "This notebook shows how to process a grid montage acquired using `SerialEM`. The data for this demo were collected on a zeolite sample (2020-02-12), using a JEOL JEM-1400 @ 120 kV in combination with a TVIPS F-416 camera. \n", 22 | "\n", 23 | "The data are available from zenodo: https://doi.org/10.5281/zenodo.3923718\n", 24 | "\n", 25 | "These data were chosen, because the stitching from SerialEM was particularly bad. We will show an example of how `PySerialEM` can be used to try to get a better montage.\n", 26 | "\n", 27 | "For this demo to work, change the `work` directory below to point at the right location." 28 | ] 29 | }, 30 | { 31 | "cell_type": "code", 32 | "execution_count": null, 33 | "metadata": {}, 34 | "outputs": [], 35 | "source": [ 36 | "from pyserialem import Montage\n", 37 | "import numpy as np\n", 38 | "from pathlib import Path\n", 39 | "np.set_printoptions(suppress=True)\n", 40 | "\n", 41 | "# work directory\n", 42 | "work = Path(r\"C:\\s\\2020-02-12\\serialem_montage\")\n", 43 | "work" 44 | ] 45 | }, 46 | { 47 | "cell_type": "markdown", 48 | "metadata": {}, 49 | "source": [ 50 | "## Setting up the montage\n", 51 | "\n", 52 | "Load the `gm.mrc` file and the associated images. For SerialEM data, the gridshape must be specified, because it cannot be obtained from the data or `.mdoc` direction. There are also several parameters to tune the orientation of the images to match them with the input of the stagematrix (if needed). First we must get the coordinate settings to match those of SerialEM. These variables ap" 53 | ] 54 | }, 55 | { 56 | "cell_type": "code", 57 | "execution_count": null, 58 | "metadata": {}, 59 | "outputs": [], 60 | "source": [ 61 | "m = Montage.from_serialem_mrc(\n", 62 | " work / 'gm.mrc', \n", 63 | " gridshape=(5,5),\n", 64 | " direction='updown',\n", 65 | " zigzag=True,\n", 66 | " flip=False,\n", 67 | " image_rot90 = 3,\n", 68 | " image_flipud = False,\n", 69 | " image_fliplr = True,\n", 70 | ")\n", 71 | "\n", 72 | "m.gridspec" 73 | ] 74 | }, 75 | { 76 | "cell_type": "markdown", 77 | "metadata": {}, 78 | "source": [ 79 | "First, we can check what the data actually look like. To do so, we can simply `stitch` and `plot` the data using a `binning=4` to conserve a bit of memory. This naively plots the data at the expected positions. Although the stitching is not that great, it's enough to get a feeling for the data.\n", 80 | "\n", 81 | "Note that SerialEM includes the pixel coordinates in the `.mdoc` file, so it is not necessary to calculate these again. Instead, the `PieceCoordinates` are mapped to `m.coords`." 82 | ] 83 | }, 84 | { 85 | "cell_type": "code", 86 | "execution_count": null, 87 | "metadata": {}, 88 | "outputs": [], 89 | "source": [ 90 | "# Use `optimized = False` to prevent using the aligned piece coordinates\n", 91 | "m.stitch(binning=4, optimized=False)\n", 92 | "m.plot()" 93 | ] 94 | }, 95 | { 96 | "cell_type": "markdown", 97 | "metadata": {}, 98 | "source": [ 99 | "SerialEM has also already calculated the aligned image coordinates (`AlignedPieceCoordsVS`/`AlignedPieceCoords`). These can be accessed via the `.optimized_coords` attribute. To plot, you can do the following:" 100 | ] 101 | }, 102 | { 103 | "cell_type": "code", 104 | "execution_count": null, 105 | "metadata": {}, 106 | "outputs": [], 107 | "source": [ 108 | "# optimized = True is the default, so it can be left out\n", 109 | "m.stitch(binning=4, optimized=True)\n", 110 | "m.plot()\n", 111 | "\n", 112 | "montage_serialem = m.stitched # store reference for later" 113 | ] 114 | }, 115 | { 116 | "cell_type": "markdown", 117 | "metadata": {}, 118 | "source": [ 119 | "The stitching from SerialEM is particularly bad, so we can try to optimize it using the algorithm in `pyserialem`.\n", 120 | "First, we must ensure that we entered the gridspec correctly. If the layout of the tiles below does not look right (i.e. similar to above), go back to loading the `Montage` and fiddle with the rotation of the images. The operation below just places the tiles at the positions calculated by `pyserialem`." 121 | ] 122 | }, 123 | { 124 | "cell_type": "code", 125 | "execution_count": null, 126 | "metadata": {}, 127 | "outputs": [], 128 | "source": [ 129 | "m.calculate_montage_coords()\n", 130 | "m.stitch(binning=4, optimized=False)\n", 131 | "m.plot()" 132 | ] 133 | }, 134 | { 135 | "cell_type": "markdown", 136 | "metadata": {}, 137 | "source": [ 138 | "It is still possible to try to get better stitching using the algorithm in `pyserialem`:\n", 139 | "\n", 140 | " 1. Better estimate the difference vectors between each tile using cross correlation\n", 141 | " 2. Optimize the coordinates of the difference vectors using least-squares minimization\n", 142 | "\n", 143 | "This approach is based on *Globally optimal stitching of tiled 3D microscopic image acquisitions* by Preibish et al., Bioinformatics 25 (2009), 1463–1465 (https://doi.org/10.1093/bioinformatics/btp184).\n", 144 | "\n", 145 | "Some metrics, such as the obtained shifts and FFT scores are plotted to evaluate the stitching." 146 | ] 147 | }, 148 | { 149 | "cell_type": "code", 150 | "execution_count": null, 151 | "metadata": {}, 152 | "outputs": [], 153 | "source": [ 154 | "# Use cross correlation to get difference vectors\n", 155 | "m.calculate_difference_vectors(\n", 156 | " threshold=0.08, \n", 157 | "# method='skimage',\n", 158 | " plot=False\n", 159 | ")\n", 160 | "\n", 161 | "# plot the fft_scores\n", 162 | "m.plot_fft_scores()\n", 163 | "\n", 164 | "# plot the pixel shifts\n", 165 | "m.plot_shifts()\n", 166 | "\n", 167 | "# get coords optimized using cross correlation\n", 168 | "m.optimize_montage_coords(plot=True)\n", 169 | "\n", 170 | "# stitch image, use binning 4 for speed-up and memory conservation\n", 171 | "m.stitch(binning=4)\n", 172 | "\n", 173 | "# plot the stitched image\n", 174 | "m.plot()\n", 175 | "\n", 176 | "montage_pyserialem = m.stitched # store reference for later" 177 | ] 178 | }, 179 | { 180 | "cell_type": "markdown", 181 | "metadata": {}, 182 | "source": [ 183 | "It's not a perfect stitching, but much better than what SerialEM produced! I believe the reason is that SerialEM does some adjustments to the stageposition as it is moving. Below is an example of the same grid map collected with [instamatic](http://github.com/instamatic-dev/instamatic), using the same coordinates and imaging conditions, reconstructed with the same algorithm." 184 | ] 185 | }, 186 | { 187 | "cell_type": "code", 188 | "execution_count": null, 189 | "metadata": {}, 190 | "outputs": [], 191 | "source": [ 192 | "import tifffile\n", 193 | "import matplotlib.pyplot as plt\n", 194 | "\n", 195 | "with tifffile.TiffFile(work / 'stitched_instamatic.tiff') as f:\n", 196 | " montage_instamatic = f.asarray()" 197 | ] 198 | }, 199 | { 200 | "cell_type": "code", 201 | "execution_count": null, 202 | "metadata": {}, 203 | "outputs": [], 204 | "source": [ 205 | "fig, (ax0, ax1, ax2) = plt.subplots(ncols=3, figsize=(20,10))\n", 206 | "\n", 207 | "ax0.imshow(montage_serialem)\n", 208 | "ax0.set_title('Data: SerialEM\\n'\n", 209 | " 'Stitching: SerialEM')\n", 210 | "\n", 211 | "ax1.imshow(montage_pyserialem)\n", 212 | "ax1.set_title('Data: SerialEM\\n'\n", 213 | " 'Stitching: PySerialEM')\n", 214 | "\n", 215 | "ax2.imshow(montage_instamatic)\n", 216 | "ax2.set_title('Data: Instamatic\\n'\n", 217 | " 'Stitching: PySerialEM');" 218 | ] 219 | }, 220 | { 221 | "cell_type": "markdown", 222 | "metadata": {}, 223 | "source": [ 224 | "When the image has been stitched (with or without optimization), we can look for the positions of the grid squares/squircles. \n", 225 | "\n", 226 | "First, we should tell `pyserialem` about the imaging conditions by setting the stagematrix to relate the pixelpositions back to stage coordinates. \n", 227 | "\n", 228 | "The easiest way to do it is to pass the `StageToCameraMatrix` directly. It can be found in `SerialEMcalibrations.txt`. Look for the last number, which gives the magnification.\n", 229 | "\n", 230 | "(They can also be set directly via `.set_stagematrix` and `.set_pixelsize`)" 231 | ] 232 | }, 233 | { 234 | "cell_type": "code", 235 | "execution_count": null, 236 | "metadata": {}, 237 | "outputs": [], 238 | "source": [ 239 | "StageToCameraMatrix = \"StageToCameraMatrix 10 0 8.797544 0.052175 0.239726 8.460119 0.741238 100\"\n", 240 | "m.set_stagematrix_from_serialem_calib(StageToCameraMatrix)\n", 241 | "\n", 242 | "m.stagematrix" 243 | ] 244 | }, 245 | { 246 | "cell_type": "markdown", 247 | "metadata": {}, 248 | "source": [ 249 | "This also sets the pixelsize." 250 | ] 251 | }, 252 | { 253 | "cell_type": "code", 254 | "execution_count": null, 255 | "metadata": {}, 256 | "outputs": [], 257 | "source": [ 258 | "m.pixelsize" 259 | ] 260 | }, 261 | { 262 | "cell_type": "markdown", 263 | "metadata": {}, 264 | "source": [ 265 | "To find the holes, call the method `.find_holes`. The grid squares are identified as objects roughly sized `diameter` with a tolerance of 10%. The median as well as 5/95 percentiles are printed to evaluate the hole size distribution. By default the `otsu` method is used to define the threshold, but the threshold can be changed if the segmentation looks poor." 266 | ] 267 | }, 268 | { 269 | "cell_type": "code", 270 | "execution_count": null, 271 | "metadata": {}, 272 | "outputs": [], 273 | "source": [ 274 | "stagecoords, imagecoords = m.find_holes(\n", 275 | " plot=True, \n", 276 | " tolerance=0.2)" 277 | ] 278 | }, 279 | { 280 | "cell_type": "markdown", 281 | "metadata": {}, 282 | "source": [ 283 | "IF a `.nav` file was saved, the stage coordinates can be added and read back into `SerialEM`." 284 | ] 285 | }, 286 | { 287 | "cell_type": "code", 288 | "execution_count": null, 289 | "metadata": {}, 290 | "outputs": [], 291 | "source": [ 292 | "from pyserialem import read_nav_file, write_nav_file\n", 293 | "\n", 294 | "nav = read_nav_file(work / \"nav.nav\")\n", 295 | "map_item = nav[0]\n", 296 | "items = map_item.add_marker_group(coords=stagecoords/1000, kind='stage')\n", 297 | "write_nav_file(\"nav_new.nav\", map_item, *items)" 298 | ] 299 | }, 300 | { 301 | "cell_type": "markdown", 302 | "metadata": {}, 303 | "source": [ 304 | "It is possible to optimize the stage coordinates for more efficient navigation. In this example, the total stage movement can be reduced by about 75%, which will save a lot of time. The function uses the _two-opt_ algorithm for finding the shortest path: https://en.wikipedia.org/wiki/2-opt." 305 | ] 306 | }, 307 | { 308 | "cell_type": "code", 309 | "execution_count": null, 310 | "metadata": {}, 311 | "outputs": [], 312 | "source": [ 313 | "from pyserialem.navigation import sort_nav_items_by_shortest_path\n", 314 | "\n", 315 | "stagecoords = sort_nav_items_by_shortest_path(\n", 316 | " stagecoords, \n", 317 | " plot=True\n", 318 | ")" 319 | ] 320 | }, 321 | { 322 | "cell_type": "markdown", 323 | "metadata": {}, 324 | "source": [ 325 | "We can re-run the command (or set the `threshold` to something like `0.01`) to try to get a better path. In this case it's possible to improve it a little bit more." 326 | ] 327 | }, 328 | { 329 | "cell_type": "code", 330 | "execution_count": null, 331 | "metadata": {}, 332 | "outputs": [], 333 | "source": [ 334 | "stagecoords = sort_nav_items_by_shortest_path(\n", 335 | " stagecoords, \n", 336 | " threshold=0.01,\n", 337 | " plot=True\n", 338 | ")" 339 | ] 340 | }, 341 | { 342 | "cell_type": "code", 343 | "execution_count": null, 344 | "metadata": {}, 345 | "outputs": [], 346 | "source": [] 347 | } 348 | ], 349 | "metadata": { 350 | "kernelspec": { 351 | "display_name": "Python 3", 352 | "language": "python", 353 | "name": "python3" 354 | }, 355 | "language_info": { 356 | "codemirror_mode": { 357 | "name": "ipython", 358 | "version": 3 359 | }, 360 | "file_extension": ".py", 361 | "mimetype": "text/x-python", 362 | "name": "python", 363 | "nbconvert_exporter": "python", 364 | "pygments_lexer": "ipython3", 365 | "version": "3.8.3" 366 | } 367 | }, 368 | "nbformat": 4, 369 | "nbformat_minor": 2 370 | } 371 | -------------------------------------------------------------------------------- /data/gm.mrc.mdoc: -------------------------------------------------------------------------------- 1 | PixelSpacing = 2312 2 | Voltage = 120 3 | ImageFile = gm.mrc 4 | ImageSize = 2048 2048 5 | Montage = 1 6 | DataMode = 1 7 | 8 | [T = SerialEM: TU Delft JEM-1400/HC w. F416 05-Feb-20 14:41:21 ] 9 | 10 | [T = Tilt axis angle = 90.6, binning = 2 spot = 3 camera = 0] 11 | 12 | [ZValue = 0] 13 | TiltAngle = -0.1 14 | PieceCoordinates = 0 0 0 15 | StagePosition = -834.368 -856.901 16 | StageZ = 45.1092 17 | Magnification = 100 18 | Intensity = 0.831815 19 | ExposureDose = 0 20 | PixelSpacing = 2312 21 | SpotSize = 3 22 | Defocus = 158.09 23 | ImageShift = 0 0 24 | RotationAngle = -179.4 25 | ExposureTime = 0.2 26 | Binning = 2 27 | CameraIndex = 0 28 | DividedBy2 = 1 29 | MagIndex = 10 30 | CountsPerElectron = 11.5 31 | MinMaxMean = 0 3490 28.0558 32 | TargetDefocus = -2 33 | DateTime = 05-Feb-20 14:42:35 34 | AlignedPieceCoordsVS = 2 46 0 35 | XedgeDxyVS = 9.19029 -228.933 0.0163485 36 | YedgeDxyVS = 74.1531 152.503 0.0220954 37 | 38 | [ZValue = 1] 39 | TiltAngle = -0.1 40 | PieceCoordinates = 0 1844 0 41 | StagePosition = -836.364 -415.786 42 | StageZ = 44.77 43 | Magnification = 100 44 | Intensity = 0.831815 45 | ExposureDose = 0 46 | PixelSpacing = 2312 47 | SpotSize = 3 48 | Defocus = 158.09 49 | ImageShift = 0 0 50 | RotationAngle = -179.4 51 | ExposureTime = 0.2 52 | Binning = 2 53 | CameraIndex = 0 54 | DividedBy2 = 1 55 | MagIndex = 10 56 | CountsPerElectron = 11.5 57 | MinMaxMean = 0 4036 1093.79 58 | TargetDefocus = -2 59 | DateTime = 05-Feb-20 14:42:46 60 | AlignedPieceCoordsVS = -93 1825 0 61 | XedgeDxyVS = -45.6485 30.9764 0.483884 62 | YedgeDxyVS = -3.90762 -85.9914 0.263254 63 | 64 | [ZValue = 2] 65 | TiltAngle = -0.1 66 | PieceCoordinates = 0 3688 0 67 | StagePosition = -838.357 25.393 68 | StageZ = 45.1092 69 | Magnification = 100 70 | Intensity = 0.831815 71 | ExposureDose = 0 72 | PixelSpacing = 2312 73 | SpotSize = 3 74 | Defocus = 158.09 75 | ImageShift = 0 0 76 | RotationAngle = -179.4 77 | ExposureTime = 0.2 78 | Binning = 2 79 | CameraIndex = 0 80 | DividedBy2 = 1 81 | MagIndex = 10 82 | CountsPerElectron = 11.5 83 | MinMaxMean = 16 4168 1678.27 84 | TargetDefocus = -2 85 | DateTime = 05-Feb-20 14:42:57 86 | AlignedPieceCoordsVS = -89 3742 0 87 | XedgeDxyVS = -42.2979 12.1043 0.481322 88 | YedgeDxyVS = -11.4596 -46.5759 0.337804 89 | 90 | [ZValue = 3] 91 | TiltAngle = -0.1 92 | PieceCoordinates = 0 5532 0 93 | StagePosition = -840.352 466.579 94 | StageZ = 44.77 95 | Magnification = 100 96 | Intensity = 0.831815 97 | ExposureDose = 0 98 | PixelSpacing = 2312 99 | SpotSize = 3 100 | Defocus = 158.09 101 | ImageShift = 0 0 102 | RotationAngle = -179.4 103 | ExposureTime = 0.2 104 | Binning = 2 105 | CameraIndex = 0 106 | DividedBy2 = 1 107 | MagIndex = 10 108 | CountsPerElectron = 11.5 109 | MinMaxMean = 0 4149 1584.15 110 | TargetDefocus = -2 111 | DateTime = 05-Feb-20 14:43:07 112 | AlignedPieceCoordsVS = -78 5615 0 113 | XedgeDxyVS = -37.9378 -5.76819 0.526583 114 | YedgeDxyVS = -15.8027 21.1993 0.26801 115 | 116 | [ZValue = 4] 117 | TiltAngle = -0.1 118 | PieceCoordinates = 0 7376 0 119 | StagePosition = -842.348 907.762 120 | StageZ = 44.77 121 | Magnification = 100 122 | Intensity = 0.831815 123 | ExposureDose = 0 124 | PixelSpacing = 2312 125 | SpotSize = 3 126 | Defocus = 158.09 127 | ImageShift = 0 0 128 | RotationAngle = -179.4 129 | ExposureTime = 0.2 130 | Binning = 2 131 | CameraIndex = 0 132 | DividedBy2 = 1 133 | MagIndex = 10 134 | CountsPerElectron = 11.5 135 | MinMaxMean = 0 3935 659.04 136 | TargetDefocus = -2 137 | DateTime = 05-Feb-20 14:43:21 138 | AlignedPieceCoordsVS = -61 7425 0 139 | XedgeDxyVS = -31.0499 -23.1416 0.512632 140 | 141 | [ZValue = 5] 142 | TiltAngle = -0.1 143 | PieceCoordinates = 1844 7376 0 144 | StagePosition = -423.167 895.064 145 | StageZ = 45.1092 146 | Magnification = 100 147 | Intensity = 0.831815 148 | ExposureDose = 0 149 | PixelSpacing = 2312 150 | SpotSize = 3 151 | Defocus = 158.09 152 | ImageShift = 0 0 153 | RotationAngle = -179.4 154 | ExposureTime = 0.2 155 | Binning = 2 156 | CameraIndex = 0 157 | DividedBy2 = 1 158 | MagIndex = 10 159 | CountsPerElectron = 11.5 160 | MinMaxMean = 0 4231 1592.92 161 | TargetDefocus = -2 162 | DateTime = 05-Feb-20 14:43:32 163 | AlignedPieceCoordsVS = 1814 7436 0 164 | XedgeDxyVS = -38.3273 -4.58142 0.447891 165 | 166 | [ZValue = 6] 167 | TiltAngle = -0.1 168 | PieceCoordinates = 1844 5532 0 169 | StagePosition = -421.175 453.88 170 | StageZ = 45.1092 171 | Magnification = 100 172 | Intensity = 0.831815 173 | ExposureDose = 0 174 | PixelSpacing = 2312 175 | SpotSize = 3 176 | Defocus = 158.09 177 | ImageShift = 0 0 178 | RotationAngle = -179.4 179 | ExposureTime = 0.2 180 | Binning = 2 181 | CameraIndex = 0 182 | DividedBy2 = 1 183 | MagIndex = 10 184 | CountsPerElectron = 11.5 185 | MinMaxMean = 0 4095 1648.19 186 | TargetDefocus = -2 187 | DateTime = 05-Feb-20 14:43:43 188 | AlignedPieceCoordsVS = 1804 5617 0 189 | XedgeDxyVS = -42.7789 17.5641 0.492803 190 | YedgeDxyVS = -9.97138 20.5049 0.279098 191 | 192 | [ZValue = 7] 193 | TiltAngle = -0.1 194 | PieceCoordinates = 1844 3688 0 195 | StagePosition = -419.177 12.7013 196 | StageZ = 44.77 197 | Magnification = 100 198 | Intensity = 0.831815 199 | ExposureDose = 0 200 | PixelSpacing = 2312 201 | SpotSize = 3 202 | Defocus = 158.09 203 | ImageShift = 0 0 204 | RotationAngle = -179.4 205 | ExposureTime = 0.2 206 | Binning = 2 207 | CameraIndex = 0 208 | DividedBy2 = 1 209 | MagIndex = 10 210 | CountsPerElectron = 11.5 211 | MinMaxMean = 18 4071 1638.4 212 | TargetDefocus = -2 213 | DateTime = 05-Feb-20 14:43:53 214 | AlignedPieceCoordsVS = 1797 3733 0 215 | XedgeDxyVS = -48.7538 36.8684 0.511795 216 | YedgeDxyVS = -5.62019 -44.7575 0.328673 217 | 218 | [ZValue = 8] 219 | TiltAngle = -0.1 220 | PieceCoordinates = 1844 1844 0 221 | StagePosition = -417.184 -428.486 222 | StageZ = 44.77 223 | Magnification = 100 224 | Intensity = 0.831815 225 | ExposureDose = 0 226 | PixelSpacing = 2312 227 | SpotSize = 3 228 | Defocus = 158.09 229 | ImageShift = 0 0 230 | RotationAngle = -179.4 231 | ExposureTime = 0.2 232 | Binning = 2 233 | CameraIndex = 0 234 | DividedBy2 = 1 235 | MagIndex = 10 236 | CountsPerElectron = 11.5 237 | MinMaxMean = 18 4092 1640.16 238 | TargetDefocus = -2 239 | DateTime = 05-Feb-20 14:44:04 240 | AlignedPieceCoordsVS = 1796 1807 0 241 | XedgeDxyVS = -55.1507 54.879 0.512049 242 | YedgeDxyVS = 0.223467 -86.3047 0.325386 243 | 244 | [ZValue = 9] 245 | TiltAngle = -0.1 246 | PieceCoordinates = 1844 0 0 247 | StagePosition = -415.19 -869.669 248 | StageZ = 44.77 249 | Magnification = 100 250 | Intensity = 0.831815 251 | ExposureDose = 0 252 | PixelSpacing = 2312 253 | SpotSize = 3 254 | Defocus = 158.09 255 | ImageShift = 0 0 256 | RotationAngle = -179.4 257 | ExposureTime = 0.2 258 | Binning = 2 259 | CameraIndex = 0 260 | DividedBy2 = 1 261 | MagIndex = 10 262 | CountsPerElectron = 11.5 263 | MinMaxMean = 0 3962 541.08 264 | TargetDefocus = -2 265 | DateTime = 05-Feb-20 14:44:20 266 | AlignedPieceCoordsVS = 1799 -127 0 267 | XedgeDxyVS = -59.4328 63.3929 0.489714 268 | YedgeDxyVS = 3.32373 -100.765 0.373795 269 | 270 | [ZValue = 10] 271 | TiltAngle = -0.1 272 | PieceCoordinates = 3688 0 0 273 | StagePosition = 3.9197 -882.296 274 | StageZ = 44.77 275 | Magnification = 100 276 | Intensity = 0.831815 277 | ExposureDose = 0 278 | PixelSpacing = 2312 279 | SpotSize = 3 280 | Defocus = 158.09 281 | ImageShift = 0 0 282 | RotationAngle = -179.4 283 | ExposureTime = 0.2 284 | Binning = 2 285 | CameraIndex = 0 286 | DividedBy2 = 1 287 | MagIndex = 10 288 | CountsPerElectron = 11.5 289 | MinMaxMean = 0 3845 658.789 290 | TargetDefocus = -2 291 | DateTime = 05-Feb-20 14:44:30 292 | AlignedPieceCoordsVS = 3703 -174 0 293 | XedgeDxyVS = -45.8384 42.2562 0.858418 294 | YedgeDxyVS = 10.5596 -86.6473 0.279173 295 | 296 | [ZValue = 11] 297 | TiltAngle = -0.1 298 | PieceCoordinates = 3688 1844 0 299 | StagePosition = 1.9943 -441.182 300 | StageZ = 45.1092 301 | Magnification = 100 302 | Intensity = 0.831815 303 | ExposureDose = 0 304 | PixelSpacing = 2312 305 | SpotSize = 3 306 | Defocus = 158.09 307 | ImageShift = 0 0 308 | RotationAngle = -179.4 309 | ExposureTime = 0.2 310 | Binning = 2 311 | CameraIndex = 0 312 | DividedBy2 = 1 313 | MagIndex = 10 314 | CountsPerElectron = 11.5 315 | MinMaxMean = 12 4074 1613.51 316 | TargetDefocus = -2 317 | DateTime = 05-Feb-20 14:44:41 318 | AlignedPieceCoordsVS = 3694 1760 0 319 | XedgeDxyVS = -37.133 25.0031 0.395986 320 | YedgeDxyVS = 3.52332 -94.0948 0.349539 321 | 322 | [ZValue = 12] 323 | TiltAngle = -0.1 324 | PieceCoordinates = 3688 3688 0 325 | StagePosition = 0 -0.0013 326 | StageZ = 45.1092 327 | Magnification = 100 328 | Intensity = 0.831815 329 | ExposureDose = 0 330 | PixelSpacing = 2312 331 | SpotSize = 3 332 | Defocus = 158.09 333 | ImageShift = 0 0 334 | RotationAngle = -179.4 335 | ExposureTime = 0.2 336 | Binning = 2 337 | CameraIndex = 0 338 | DividedBy2 = 1 339 | MagIndex = 10 340 | CountsPerElectron = 11.5 341 | MinMaxMean = 19 4144 1607.56 342 | TargetDefocus = -2 343 | DateTime = 05-Feb-20 14:44:52 344 | AlignedPieceCoordsVS = 3690 3698 0 345 | XedgeDxyVS = -30.864 7.22256 0.476601 346 | YedgeDxyVS = -1.56209 -54.7659 0.27359 347 | 348 | [ZValue = 13] 349 | TiltAngle = -0.1 350 | PieceCoordinates = 3688 5532 0 351 | StagePosition = -1.9943 441.182 352 | StageZ = 45.1092 353 | Magnification = 100 354 | Intensity = 0.831815 355 | ExposureDose = 0 356 | PixelSpacing = 2312 357 | SpotSize = 3 358 | Defocus = 158.09 359 | ImageShift = 0 0 360 | RotationAngle = -179.4 361 | ExposureTime = 0.2 362 | Binning = 2 363 | CameraIndex = 0 364 | DividedBy2 = 1 365 | MagIndex = 10 366 | CountsPerElectron = 11.5 367 | MinMaxMean = 11 4178 1600.18 368 | TargetDefocus = -2 369 | DateTime = 05-Feb-20 14:45:03 370 | AlignedPieceCoordsVS = 3691 5595 0 371 | XedgeDxyVS = -26.1411 -10.7959 0.448878 372 | YedgeDxyVS = -6.21769 18.0531 0.269741 373 | 374 | [ZValue = 14] 375 | TiltAngle = -0.1 376 | PieceCoordinates = 3688 7376 0 377 | StagePosition = -3.9886 882.368 378 | StageZ = 44.77 379 | Magnification = 100 380 | Intensity = 0.831815 381 | ExposureDose = 0 382 | PixelSpacing = 2312 383 | SpotSize = 3 384 | Defocus = 158.09 385 | ImageShift = 0 0 386 | RotationAngle = -179.4 387 | ExposureTime = 0.2 388 | Binning = 2 389 | CameraIndex = 0 390 | DividedBy2 = 1 391 | MagIndex = 10 392 | CountsPerElectron = 11.5 393 | MinMaxMean = 6 4394 1630.51 394 | TargetDefocus = -2 395 | DateTime = 05-Feb-20 14:45:17 396 | AlignedPieceCoordsVS = 3697 7421 0 397 | XedgeDxyVS = -21.1437 -28.0841 0.38966 398 | 399 | [ZValue = 15] 400 | TiltAngle = -0.1 401 | PieceCoordinates = 5532 7376 0 402 | StagePosition = 415.19 869.669 403 | StageZ = 44.77 404 | Magnification = 100 405 | Intensity = 0.831815 406 | ExposureDose = 0 407 | PixelSpacing = 2312 408 | SpotSize = 3 409 | Defocus = 158.09 410 | ImageShift = 0 0 411 | RotationAngle = -179.4 412 | ExposureTime = 0.2 413 | Binning = 2 414 | CameraIndex = 0 415 | DividedBy2 = 1 416 | MagIndex = 10 417 | CountsPerElectron = 11.5 418 | MinMaxMean = 0 4066 1385.62 419 | TargetDefocus = -2 420 | DateTime = 05-Feb-20 14:45:28 421 | AlignedPieceCoordsVS = 5563 7431 0 422 | XedgeDxyVS = -31.7651 -26.6323 0.694758 423 | 424 | [ZValue = 16] 425 | TiltAngle = -0.1 426 | PieceCoordinates = 5532 5532 0 427 | StagePosition = 417.184 428.487 428 | StageZ = 45.1092 429 | Magnification = 100 430 | Intensity = 0.831815 431 | ExposureDose = 0 432 | PixelSpacing = 2312 433 | SpotSize = 3 434 | Defocus = 158.09 435 | ImageShift = 0 0 436 | RotationAngle = -179.4 437 | ExposureTime = 0.2 438 | Binning = 2 439 | CameraIndex = 0 440 | DividedBy2 = 1 441 | MagIndex = 10 442 | CountsPerElectron = 11.5 443 | MinMaxMean = 15 4051 1602.57 444 | TargetDefocus = -2 445 | DateTime = 05-Feb-20 14:45:38 446 | AlignedPieceCoordsVS = 5562 5599 0 447 | XedgeDxyVS = -36.9413 -7.85742 0.394231 448 | YedgeDxyVS = -1.68544 17.8352 0.357148 449 | 450 | [ZValue = 17] 451 | TiltAngle = -0.1 452 | PieceCoordinates = 5532 3688 0 453 | StagePosition = 419.18 -12.6972 454 | StageZ = 44.77 455 | Magnification = 100 456 | Intensity = 0.831815 457 | ExposureDose = 0 458 | PixelSpacing = 2312 459 | SpotSize = 3 460 | Defocus = 158.09 461 | ImageShift = 0 0 462 | RotationAngle = -179.4 463 | ExposureTime = 0.2 464 | Binning = 2 465 | CameraIndex = 0 466 | DividedBy2 = 1 467 | MagIndex = 10 468 | CountsPerElectron = 11.5 469 | MinMaxMean = 13 3991 1569.68 470 | TargetDefocus = -2 471 | DateTime = 05-Feb-20 14:45:50 472 | AlignedPieceCoordsVS = 5565 3693 0 473 | XedgeDxyVS = -42.4891 8.6479 0.386072 474 | YedgeDxyVS = 2.09192 -53.7215 0.286441 475 | 476 | [ZValue = 18] 477 | TiltAngle = -0.1 478 | PieceCoordinates = 5532 1844 0 479 | StagePosition = 421.174 -453.882 480 | StageZ = 44.77 481 | Magnification = 100 482 | Intensity = 0.831815 483 | ExposureDose = 0 484 | PixelSpacing = 2312 485 | SpotSize = 3 486 | Defocus = 158.09 487 | ImageShift = 0 0 488 | RotationAngle = -179.4 489 | ExposureTime = 0.2 490 | Binning = 2 491 | CameraIndex = 0 492 | DividedBy2 = 1 493 | MagIndex = 10 494 | CountsPerElectron = 11.5 495 | MinMaxMean = 2 3939 1450.65 496 | TargetDefocus = -2 497 | DateTime = 05-Feb-20 14:46:01 498 | AlignedPieceCoordsVS = 5575 1747 0 499 | XedgeDxyVS = -48.8713 26.2109 0.360537 500 | YedgeDxyVS = 9.72217 -93.7445 0.281802 501 | 502 | [ZValue = 19] 503 | TiltAngle = -0.1 504 | PieceCoordinates = 5532 0 0 505 | StagePosition = 423.165 -895.058 506 | StageZ = 44.77 507 | Magnification = 100 508 | Intensity = 0.831815 509 | ExposureDose = 0 510 | PixelSpacing = 2312 511 | SpotSize = 3 512 | Defocus = 158.09 513 | ImageShift = 0 0 514 | RotationAngle = -179.4 515 | ExposureTime = 0.2 516 | Binning = 2 517 | CameraIndex = 0 518 | DividedBy2 = 1 519 | MagIndex = 10 520 | CountsPerElectron = 11.5 521 | MinMaxMean = 0 3747 172.829 522 | TargetDefocus = -2 523 | DateTime = 05-Feb-20 14:46:17 524 | AlignedPieceCoordsVS = 5592 -204 0 525 | XedgeDxyVS = 186.908 -159.034 0.0162667 526 | YedgeDxyVS = 15.6871 -95.8101 0.302945 527 | 528 | [ZValue = 20] 529 | TiltAngle = -0.1 530 | PieceCoordinates = 7376 0 0 531 | StagePosition = 842.279 -907.69 532 | StageZ = 45.1092 533 | Magnification = 100 534 | Intensity = 0.831815 535 | ExposureDose = 0 536 | PixelSpacing = 2312 537 | SpotSize = 3 538 | Defocus = 158.09 539 | ImageShift = 0 0 540 | RotationAngle = -179.4 541 | ExposureTime = 0.2 542 | Binning = 2 543 | CameraIndex = 0 544 | DividedBy2 = 1 545 | MagIndex = 10 546 | CountsPerElectron = 11.5 547 | MinMaxMean = 0 56 0.328693 548 | TargetDefocus = -2 549 | DateTime = 05-Feb-20 14:46:27 550 | AlignedPieceCoordsVS = 7378 46 0 551 | YedgeDxyVS = 367.099 77.956 0.0140394 552 | 553 | [ZValue = 21] 554 | TiltAngle = -0.1 555 | PieceCoordinates = 7376 1844 0 556 | StagePosition = 840.351 -466.579 557 | StageZ = 45.1092 558 | Magnification = 100 559 | Intensity = 0.831815 560 | ExposureDose = 0 561 | PixelSpacing = 2312 562 | SpotSize = 3 563 | Defocus = 158.09 564 | ImageShift = 0 0 565 | RotationAngle = -179.4 566 | ExposureTime = 0.2 567 | Binning = 2 568 | CameraIndex = 0 569 | DividedBy2 = 1 570 | MagIndex = 10 571 | CountsPerElectron = 11.5 572 | MinMaxMean = 0 3746 350.547 573 | TargetDefocus = -2 574 | DateTime = 05-Feb-20 14:46:38 575 | AlignedPieceCoordsVS = 7467 1735 0 576 | YedgeDxyVS = 15.7341 -92.6019 0.532819 577 | 578 | [ZValue = 22] 579 | TiltAngle = -0.1 580 | PieceCoordinates = 7376 3688 0 581 | StagePosition = 838.358 -25.393 582 | StageZ = 45.1092 583 | Magnification = 100 584 | Intensity = 0.831815 585 | ExposureDose = 0 586 | PixelSpacing = 2312 587 | SpotSize = 3 588 | Defocus = 158.09 589 | ImageShift = 0 0 590 | RotationAngle = -179.4 591 | ExposureTime = 0.2 592 | Binning = 2 593 | CameraIndex = 0 594 | DividedBy2 = 1 595 | MagIndex = 10 596 | CountsPerElectron = 11.5 597 | MinMaxMean = 0 3861 1002.55 598 | TargetDefocus = -2 599 | DateTime = 05-Feb-20 14:46:50 600 | AlignedPieceCoordsVS = 7451 3687 0 601 | YedgeDxyVS = 7.97644 -54.7383 0.279186 602 | 603 | [ZValue = 23] 604 | TiltAngle = -0.1 605 | PieceCoordinates = 7376 5532 0 606 | StagePosition = 836.362 415.786 607 | StageZ = 45.1092 608 | Magnification = 100 609 | Intensity = 0.831815 610 | ExposureDose = 0 611 | PixelSpacing = 2312 612 | SpotSize = 3 613 | Defocus = 158.09 614 | ImageShift = 0 0 615 | RotationAngle = -179.4 616 | ExposureTime = 0.2 617 | Binning = 2 618 | CameraIndex = 0 619 | DividedBy2 = 1 620 | MagIndex = 10 621 | CountsPerElectron = 11.5 622 | MinMaxMean = 0 3995 953.422 623 | TargetDefocus = -2 624 | DateTime = 05-Feb-20 14:47:01 625 | AlignedPieceCoordsVS = 7443 5602 0 626 | YedgeDxyVS = 3.78357 9.76248 0.579071 627 | 628 | [ZValue = 24] 629 | TiltAngle = -0.15 630 | PieceCoordinates = 7376 7376 0 631 | StagePosition = 834.368 856.972 632 | StageZ = 44.77 633 | Magnification = 100 634 | Intensity = 0.831815 635 | ExposureDose = 0 636 | PixelSpacing = 2312 637 | SpotSize = 3 638 | Defocus = 158.09 639 | ImageShift = 0 0 640 | RotationAngle = -179.4 641 | ExposureTime = 0.2 642 | Binning = 2 643 | CameraIndex = 0 644 | DividedBy2 = 1 645 | MagIndex = 10 646 | CountsPerElectron = 11.5 647 | MinMaxMean = 0 3933 226.855 648 | TargetDefocus = -2 649 | DateTime = 05-Feb-20 14:47:17 650 | AlignedPieceCoordsVS = 7439 7446 0 651 | 652 | [MontSection = 0] 653 | TiltAngle = -0.15 654 | PieceCoordinates = 7376 7376 0 655 | StagePosition = 0 0 656 | StageZ = 44.77 657 | Magnification = 100 658 | Intensity = 0.831815 659 | ExposureDose = 0 660 | PixelSpacing = 2312 661 | SpotSize = 3 662 | Defocus = 158.09 663 | ImageShift = 0 0 664 | RotationAngle = -179.4 665 | ExposureTime = 0.2 666 | Binning = 2 667 | CameraIndex = 0 668 | DividedBy2 = 0 669 | MagIndex = 10 670 | CountsPerElectron = 11.5 671 | TargetDefocus = -2 672 | DateTime = 05-Feb-20 14:47:17 673 | BufISXY = 0 0 674 | ProbeMode = 1 675 | MoveStage = 1 676 | ConSetUsed = 6 0 677 | MontBacklash = 10 -10 678 | ValidBacklash = 0 0 679 | DriftSettling = 0.01 680 | CameraModes = 0 0 681 | Alpha = 0 682 | FilterState = 0 0 683 | -------------------------------------------------------------------------------- /pyserialem/reader.py: -------------------------------------------------------------------------------- 1 | import random 2 | import re 3 | from collections import defaultdict 4 | from pathlib import Path 5 | 6 | import matplotlib as mpl 7 | import matplotlib.pyplot as plt 8 | import numpy as np 9 | from matplotlib import patches 10 | 11 | from .utils import bin_ndarray 12 | 13 | 14 | __all__ = [ 15 | 'bin_ndarray', 16 | 'stitch_map_items', 17 | 'NavItem', 18 | 'MapItem', 19 | 'block2dict', 20 | 'block2nav', 21 | 'read_nav_file', 22 | 'write_nav_file', 23 | 'read_mdoc_file', 24 | ] 25 | 26 | # int 27 | INTEGER = ('Color', 'NumPts', 'Draw', 'Regis', 28 | 'MapMontage', 'MapSection', 'MapBinning', 'MapMagInd', 29 | 'MapCamera', 'ShutterMode', 'MapSpotSize', 30 | 'MapSlitIn', 'ImageType', 'MontUseStage', 31 | 'MapProbeMode', 'MapLDConSet', 'Type', 'GroupID', 32 | 'MapID', 'PieceOn', 'Acquire', 'DrawnID', 33 | 'MontBinning', 'SamePosId', 'OrigReg', 34 | # mdoc 35 | 'SpotSize', 36 | 'Binning', 'CameraIndex', 'DividedBy2', 'MagIndex', 37 | 'Magnification', 'ProbeMode', 'MoveStage', 38 | 'Alpha', 'ImageSize', 'DataMode', 'Montage', 39 | 'ImageSeries', 'UsingCDS', 'LowDoseConSet', 'NumSubFrames', 40 | # other 41 | 'Corner', 'Imported', 'K2ReadMode', 'MapAlpha', 42 | 'PolyID', 'RealignReg', 'RealignedID', 'RegPt', 43 | 'RegisteredToID', 'RotOnLoad', 44 | # DE-12 45 | 'DE12-TotalNumberOfFrames', 46 | 'DE12-FramesPerSecond', 47 | 'DE12-CameraPosition', 48 | 'DE12-ProtectionCoverMode', 49 | 'DE12-ProtectionCoverOpenDelay(ms)', 50 | 'DE12-TemperatureDetector(C)', 51 | 'DE12-SensorModuleSerialNumber', 52 | 'DE12-SensorReadoutDelay(ms)', 53 | 'DE12-IgnoredFramesInSummedImage', 54 | ) 55 | 56 | # float 57 | FLOAT = ('MapExposure', 'MapIntensity', 'MapTiltAngle', 'MapSettling', 58 | # .mdoc 59 | 'StageZ', 'PixelSpacing', 'Defocus', 'RotationAngle', 60 | 'CountsPerElectron', 'TargetDefocus', 'TiltAngle', 'ExposureTime', 61 | 'DriftSettling', 'Intensity', 'ExposureDose', 'PriorRecordDose', 62 | # other 63 | 'DefocusOffset', 'FocusAxisPos', 'MapSlitWidth', 64 | # DE-12 65 | 'DE12-ServerSoftwareVersion', 66 | 'DE12-PreexposureTime(s)', 67 | 'DE12-FaradayPlatePeakReading(pA/cm2)', 68 | ) 69 | 70 | # str 71 | STRING = ('MapFile', 'Note', 72 | # .mdoc 73 | 'DateTime', 'ImageFile', 'NavigatorLabel', 74 | 'SubFramePath', 'ChannelName', 75 | ) 76 | 77 | # list, float 78 | FLOAT_LIST = ('StageXYZ', 'RawStageXY', 'MapScaleMat', 'XYinPc', 79 | 'PtsX', 'PtsY', 'StageXYZ', 'MapMinMaxScale', 80 | # .mdoc 81 | 'StagePosition', 'MinMaxMean', 'XedgeDxyVS', 'YedgeDxyVS', 82 | 'XedgeDxy', 'YedgeDxy', 'ImageShift', 'BufISXY', 83 | # other 84 | 'BklshXY', 'FocusOffsets', 'LocalErrXY', 'NetViewShiftXY', 85 | 'RealignErrXY', 'ViewBeamShiftXY', 'ViewBeamTiltXY', 86 | 'SuperMontCoords', 'StageOffsets', 'FrameDosesAndNumbers', 87 | 'FilterSlitAndLoss', 88 | # external 89 | 'CoordsInMap', 'CoordsInAliMont', 'CoordsInAliMontVS', 'CoordsInPiece', 90 | ) 91 | 92 | # list, int 93 | INTEGER_LIST = ('MapWidthHeight', 'MapFramesXY', 94 | # .mdoc 95 | 'PieceCoordinates', 'AlignedPieceCoordsVS', 96 | 'AlignedPieceCoords', 'MontBacklash', 97 | 'ValidBacklash', 'CameraModes', 'FilterState', 98 | 'ConSetUsed', 'MultishotHoleAndPosition', 99 | # other 100 | 'HoleArray', 'LDAxisAngle', 'SkipHoles', 101 | 'SuperMontXY', 102 | ) 103 | 104 | UNDEFINED = () 105 | 106 | REQUIRED_MAPITEM = ('StageXYZ', 'MapFile', 'MapSection', 107 | 'MapBinning', 'MapMagInd', 'MapScaleMat', 108 | 'MapWidthHeight', 'Color', 'Regis', 109 | 'Type', 'MapID', 'MapMontage', 'MapCamera', 110 | 'NumPts', 'PtsX', 'PtsY', 111 | ) 112 | 113 | 114 | def stitch_map_items(items: list, 115 | vmin: int = None, 116 | vmax: int = None, 117 | binning: int = 16, 118 | markers: list = None, 119 | scatter_kwargs: dict = {}, 120 | color: str = 'red', 121 | label: bool = True, 122 | ) -> None: 123 | """Take a list of MapItems and plot them at scale with respect to each 124 | other. 125 | 126 | Parameters 127 | ---------- 128 | vmax : int 129 | Passed to plt.imshow to tune the contrast. 130 | vmin : int 131 | Passed to plt.imshow to tune the contrast. 132 | binning : int 133 | Bin the loaded images before displaying them to save 134 | on memory and computation times 135 | markers: list of arrays (Mx2) 136 | Must be the same length as `items`. Each item in markers 137 | is a numpy array (Mx2) containing x/y pixel coordinates (i.e. of 138 | particle positions) to plot on the stitched map. 139 | scatter_kwargs: dict 140 | Passed to `plt.scatter` to style the points passed using 141 | `markers`. 142 | color: str 143 | Color of all the decorations (markers, borders, text) 144 | label: bool 145 | Label each frame with the sequence number 146 | """ 147 | _scatter_kwargs = { 148 | 'marker': '.', 149 | 'color': 'blue', 150 | 's': 2, 151 | } 152 | 153 | _scatter_kwargs.update(scatter_kwargs) 154 | 155 | positions = [] 156 | 157 | fig, ax = plt.subplots() 158 | 159 | try: 160 | tag_min = min(int(item.tag) for item in items) 161 | except ValueError: 162 | tag_min = None 163 | 164 | xy_coords = [] 165 | 166 | for i, item in enumerate(items): 167 | sx, sy = item.stage_xy 168 | pos_x, pos_y = item.stage_to_pixelcoords((sx / 1000, sy / 1000)) 169 | 170 | img = item.load_image() 171 | 172 | if binning: 173 | img = bin_ndarray(img, binning=binning) 174 | pos_x /= binning 175 | pos_y /= binning 176 | 177 | positions.append((pos_x, pos_y)) 178 | 179 | shape_x, shape_y = img.shape 180 | shape_x = shape_x // 2 181 | shape_y = shape_y // 2 182 | 183 | im = ax.imshow(img, 184 | interpolation='bilinear', 185 | extent=[pos_y - shape_y, pos_y + shape_y, pos_x - shape_x, pos_x + shape_x], 186 | clip_on=True, 187 | vmax=vmax, vmin=vmin) 188 | 189 | rect = patches.Rectangle((pos_y - shape_y, pos_x - shape_x), shape_y * 2, shape_x * 2, 190 | fill=False, 191 | edgecolor=color, 192 | linewidth=1) 193 | ax.add_patch(rect) 194 | 195 | if label: 196 | try: 197 | item.tag = str(int(item.tag) - tag_min) 198 | except ValueError: 199 | tag = item.tag 200 | 201 | ax.text(pos_y, pos_x, tag, ha='center', va='center', color=color) 202 | 203 | if markers: 204 | xy_coord = markers[i] 205 | xy_coord = np.array((-1, 1)) * xy_coord / binning + np.array((pos_x, pos_y)) - np.array((-shape_x, shape_y)) 206 | xy_coords.append(xy_coord) 207 | 208 | ax.scatter(0, 0, color=color, marker='+', label='Origin') 209 | title = f'Overview of {len(items)} frames' 210 | 211 | if markers: 212 | xy_coords = np.vstack(xy_coords) 213 | x, y = xy_coords.T 214 | 215 | n_tot = len(xy_coords) 216 | n_avg = len(xy_coords) / len(items) 217 | ax.scatter(y, x, **_scatter_kwargs, label='Particles') 218 | title += f'\n{n_tot} particles (average: {n_avg:.3f} / frame)' 219 | 220 | positions = np.array(positions) 221 | xmin, ymin = positions.min(axis=0) 222 | xmax, ymax = positions.max(axis=0) 223 | ax.set_ylim(xmin - shape_x, xmax + shape_x) 224 | ax.set_xlim(ymin - shape_x, ymax + shape_y) 225 | ax.axis('off') 226 | plt.title(title) 227 | plt.show() 228 | 229 | 230 | def item_to_string(d: dict, tag: str): 231 | """Turn a SerialEM key/values dictionary into a .mdoc/.nav formatted 232 | string.""" 233 | s = f'[Item = {tag}]\n' 234 | 235 | for key in sorted(d.keys()): 236 | val = d[key] 237 | 238 | try: 239 | if key in INTEGER: 240 | val = str(val) 241 | elif key in FLOAT: 242 | val = str(val) 243 | elif key in FLOAT_LIST: 244 | val = ' '.join([str(x) for x in val]) 245 | elif key in INTEGER_LIST: 246 | val = ' '.join([str(x) for x in val]) 247 | except TypeError as e: 248 | print(e) 249 | print(key, val) 250 | 251 | s += f'{key} = {val}\n' 252 | 253 | s += '' 254 | return s 255 | 256 | 257 | class NavItem: 258 | """DataClass for SerialEM Nav items. 259 | 260 | Type: 261 | 0: Marker 262 | 1: Polygon 263 | 2: Map 264 | """ 265 | 266 | TAG_ID_ITERATOR = 1 267 | # MAP_ID_ITERATOR = 1 268 | 269 | def __init__(self, d: dict, tag: str): 270 | super().__init__() 271 | # if not "MapID" in d: 272 | # d["MapID"] = NavItem.MAP_ID_ITERATOR 273 | # NavItem.MAP_ID_ITERATOR += 1 274 | 275 | self._keys = tuple(d.keys()) 276 | 277 | self.DrawnID = 0 278 | self.Acquire = 0 279 | self.__dict__.update(d) 280 | 281 | if not tag: 282 | tag = f'Item-{NavItem.TAG_ID_ITERATOR}' 283 | NavItem.TAG_ID_ITERATOR += 1 284 | 285 | self.tag = tag 286 | 287 | def __repr__(self): 288 | return f'{self.__class__.__name__}({self.kind}[Item = {self.tag}])' 289 | 290 | @property 291 | def kind(self) -> str: 292 | return ('Marker', 'Polygon', 'Map')[self.Type] 293 | 294 | @property 295 | def stage_x(self) -> float: 296 | return self.StageXYZ[0] 297 | 298 | @property 299 | def stage_y(self) -> float: 300 | return self.StageXYZ[1] 301 | 302 | @property 303 | def stage_z(self) -> list: 304 | return self.StageXYZ[2] 305 | 306 | @property 307 | def stage_xy(self) -> list: 308 | return self.StageXYZ[0:2] 309 | 310 | def to_string(self) -> str: 311 | """Convert nav item to string that can be printed to .nav file.""" 312 | d = self.to_dict() 313 | return item_to_string(d, tag=self.tag) 314 | 315 | def to_dict(self) -> dict: 316 | """Convert nav item back to dictionary.""" 317 | return {key: self.__dict__[key] for key in self._keys} 318 | 319 | @property 320 | def color_rgba(self) -> tuple: 321 | """Return matplotlib RGBA color.""" 322 | return mpl.colors.to_rgba(self.color_str, alpha=None) 323 | 324 | @property 325 | def color_str(self) -> str: 326 | """Return color as string.""" 327 | return ('red', 'green', 'blue', 'yellow', 'magenta', 'black')[self.Color] 328 | 329 | 330 | class MapItem(NavItem): 331 | """Adds some extra methods for map items.""" 332 | 333 | GROUP_ID_ITERATOR = random.randint(1, 90000) 334 | 335 | def __init__(self, *args, data_drc: str = '.', **kwargs): 336 | super().__init__(*args, **kwargs) 337 | 338 | self.validate() 339 | self.markers = {} 340 | self.data_drc = data_drc 341 | 342 | @property 343 | def stagematrix(self) -> 'np.array': 344 | # MapScaleMat already has binning embedded 345 | # MapBinning = self.MapBinning 346 | 347 | try: 348 | MontBinning = self.MontBinning 349 | except AttributeError: 350 | MontBinning = 1 351 | stagematrix = (1 / MontBinning) * np.array(self.MapScaleMat).reshape(2, 2) 352 | 353 | # FIXME: why do we need the transpose? 354 | stagematrix = stagematrix.T 355 | 356 | return stagematrix 357 | 358 | def pixel_to_stagecoords(self, coords: list) -> 'np.array': 359 | """Convert from pixel coordinates to stage coordinates.""" 360 | coords = np.array(coords) 361 | cp = np.array(self.MapWidthHeight) / 2 362 | cs = np.array(self.StageXYZ)[0:2] 363 | mati = np.linalg.inv(self.stagematrix) 364 | 365 | return np.dot(coords - cp, mati) + cs 366 | 367 | def stage_to_pixelcoords(self, coords: list) -> 'np.array': 368 | """Convert from stage coordinates to pixel coordinates.""" 369 | coords = np.array(coords) 370 | cp = np.array(self.MapWidthHeight) / 2 371 | cs = np.array(self.StageXYZ)[0:2] 372 | mat = self.stagematrix 373 | 374 | return np.dot(coords - cs, mat) + cp 375 | 376 | def load_image(self, drc: str = None) -> 'np.array': 377 | """Loads the image corresponding to this item.""" 378 | import mrcfile 379 | 380 | if not drc: 381 | drc = self.data_drc 382 | drc = Path(drc) 383 | 384 | map_file = Path(self.MapFile) 385 | if not map_file.exists(): 386 | map_file = drc / Path(self.MapFile).name 387 | 388 | m = mrcfile.mmap(map_file) 389 | s = self.MapSection 390 | if m.is_single_image() and s == 0: 391 | return np.array(m.data) 392 | else: 393 | return np.array(m.data[s]) 394 | 395 | def plot_image(self, markers: bool = True, drc: str = None) -> None: 396 | """Plot the image including markers (optional)""" 397 | import matplotlib.pyplot as plt 398 | 399 | if markers is True: 400 | markers = self.markers.values() 401 | elif isinstance(markers, dict): 402 | markers = markers.values() 403 | elif isinstance(markers, (list, tuple, np.ndarray)): 404 | pass 405 | else: 406 | markers = [] 407 | 408 | im = self.load_image() 409 | plt.matshow(im, vmax=np.percentile(im, 99)) 410 | yres = self.MapWidthHeight[1] 411 | 412 | coords = [] 413 | for marker in markers: 414 | if isinstance(marker, NavItem): 415 | xy = np.array([marker.stage_x, marker.stage_y]) 416 | px, py = self.stage_to_pixelcoords(xy) 417 | py = yres - py 418 | else: 419 | py, px = marker 420 | 421 | coords.append((px, py)) 422 | 423 | if coords: 424 | px, py = np.array(coords).T 425 | plt.plot(px, py, 'ro', markerfacecolor='none', markersize=20, markeredgewidth=2) 426 | 427 | plt.show() 428 | 429 | def add_marker(self, 430 | coord: tuple, 431 | kind: str = 'pixel', 432 | tag: str = None, 433 | acquire: bool = True, 434 | ) -> 'NavItem': 435 | """Add pixel or stage coordinate as marker to a map item. Markers are 436 | linked to this `MapItem` via the `.markers` attribute. 437 | 438 | Parameters 439 | ---------- 440 | coord : array (n x 2) 441 | List of X, Y pixel coordinates or stage coordinates corresponding to the navigation item. 442 | kind : str 443 | Defines the kind of coordinate supplied, must be one of `pixel` or `stage`. Stage coordinates 444 | are given in μm. 445 | tag : str 446 | Simple name tag for the item. It will be generated automatically if it is not given. 447 | acquire : bool 448 | Turn on the acquire flag for this item. 449 | 450 | Returns 451 | ------- 452 | Instance of `NavItem` 453 | """ 454 | if kind == 'pixel': 455 | py, px = coord 456 | yres = self.MapWidthHeight[1] 457 | py = yres - py 458 | stage_x, stage_y = self.pixel_to_stagecoords((px, py)) 459 | else: 460 | stage_x, stage_y = coord 461 | 462 | d = {} 463 | try: 464 | d['BklshXY'] = self.BklshXY 465 | except AttributeError: 466 | d['BklshXY'] = 10, -10 467 | d['Color'] = 0 468 | d['DrawnID'] = self.MapID 469 | d['GroupID'] = MapItem.GROUP_ID_ITERATOR 470 | # d["MapID"] = 1 + self.i # must be a unique ID under 100000 [optional, default=0] 471 | d['Acquire'] = int(acquire) 472 | d['NumPts'] = 1 473 | d['PtsX'] = [stage_x] 474 | d['PtsY'] = [stage_y] 475 | d['Regis'] = self.Regis 476 | d['StageXYZ'] = [stage_x, stage_y, self.stage_z] 477 | d['Type'] = 0 478 | if kind == 'pixel': 479 | d['pixel_coord'] = coord 480 | 481 | item = NavItem(d, tag=tag) 482 | 483 | self.markers[item.tag] = item 484 | 485 | return item 486 | 487 | def add_marker_group(self, 488 | coords: list, 489 | kind: str = 'pixel', 490 | acquire: bool = True, 491 | replace: bool = True, 492 | ) -> list: 493 | """Add pixel coordinates (numpy) as markers to a map item If 494 | `replace==True`, replace the entire list of existing markers on this 495 | `MapItem` (via `.markers`). 496 | 497 | Parameters 498 | ---------- 499 | coords : array (n x 2) 500 | List of X, Y pixel coordinates or stage coordinates corresponding to the navigation item. 501 | kind : str 502 | Defines the kind of coordinate supplied, must be one of `pixel` or `stage`. Stage coordinates 503 | are given in μm. 504 | acquire : bool 505 | Turn on the acquire flag for this item. 506 | replace : bool 507 | Replace the exisiting items on this instance of `MapItem` 508 | 509 | Returns 510 | ------- 511 | List of `NavItem` instances 512 | """ 513 | if replace: 514 | self.markers = {} 515 | 516 | ret = [] 517 | for i, coord in enumerate(coords): 518 | tag = f'{self.tag}-{i}' 519 | item = self.add_marker(coord, kind=kind, tag=tag, acquire=True) 520 | ret.append(item) 521 | 522 | MapItem.GROUP_ID_ITERATOR += 1 523 | 524 | return ret 525 | 526 | def update_markers(self, *items): 527 | """Update the list of markers belonging to this `Map` with the given 528 | items.""" 529 | for item in items: 530 | self.markers[item.tag] = item 531 | 532 | def set_markers(self, *items): 533 | """Replace the list of markers belonging to this `Map` with the given 534 | items.""" 535 | self.markers = {} 536 | self.update_markers(*items) 537 | 538 | def markers_as_pixel_coordinates(self): 539 | """Return markers as (Mx2) array of pixel xy coordinates.""" 540 | try: 541 | return np.array([marker.pixel_coord for marker in self.markers.values()]) 542 | except AttributeError: 543 | raise NotImplementedError() # This function is broken in some cases 544 | markers = self.markers_as_stage_coordinates() 545 | return self.stage_to_pixelcoords(markers) 546 | 547 | def markers_as_stage_coordinates(self): 548 | """Return markers as (Mx2) array of stage xy coordinates (µm).""" 549 | return np.array([marker.stage_xy for marker in self.markers.values()]) 550 | 551 | @classmethod 552 | def from_dict(cls, dct, tag: str = None): 553 | """Construct a new map item from a dictionary. 554 | 555 | For the required keys, see `serialem.REQUIRED_MAPITEM 556 | 557 | Parameters 558 | ---------- 559 | dct : dict 560 | Dictionary of required items 561 | tag : str 562 | Name to identify the map item by 563 | 564 | Returns 565 | ------- 566 | map_item : MapItem 567 | """ 568 | MapID = MapItem.GROUP_ID_ITERATOR 569 | MapItem.GROUP_ID_ITERATOR += 1 570 | 571 | # required items that can be generated 572 | map_dct = { 573 | 'Color': 2, 574 | 'Regis': 1, 575 | 'Type': 2, 576 | 'MapID': MapID, 577 | 'MapMontage': 0, 578 | 'MapCamera': 0, 579 | 'NumPts': 5, # number of points describing square around it? 580 | 'PtsX': (-1, 1, 1, -1, -1), # draw square around point, grid coordinates 581 | 'PtsY': (-1, -1, 1, 1, -1), # draw square around point, grid coordinates 582 | } 583 | 584 | map_dct.update(dct) 585 | 586 | if not tag: 587 | tag = str(MapID) 588 | 589 | map_item = cls(map_dct, tag=tag) 590 | map_item.calculate_PtsXY() 591 | 592 | return map_item 593 | 594 | def validate(self) -> None: 595 | """Validate the dictionary. 596 | 597 | Check whether all necessary keys are present 598 | """ 599 | for key in REQUIRED_MAPITEM: 600 | if key not in self.__dict__: 601 | raise KeyError(f'MapItem: missing key `{key}`') 602 | 603 | def calculate_PtsXY(self) -> None: 604 | """Calculate PtsX / PtsY from the `map_item` information 605 | (MapWidthHeight) via `.pixel_to_stagecoords`. 606 | 607 | Updates the internal values. 608 | """ 609 | x, y = self.MapWidthHeight 610 | 611 | pts = np.array(( 612 | (0, 0), 613 | (x, 0), 614 | (x, y), 615 | (0, y), 616 | (0, 0), 617 | )) 618 | 619 | coords = self.pixel_to_stagecoords(pts) 620 | 621 | PtsX, PtsY = coords.T 622 | self.PtsX = PtsX.tolist() 623 | self.PtsY = PtsY.tolist() 624 | 625 | 626 | def block2dict(block: list, kind: str = None, sequence: int = -1) -> dict: 627 | """Takes a text block from a SerialEM .nav file and converts it into a 628 | dictionary.""" 629 | patt_split = re.compile(r'\s?=\s?') 630 | d = {} 631 | 632 | for item in block: 633 | key, value = re.split(patt_split, item) 634 | 635 | try: 636 | if key in INTEGER: 637 | value = int(value) 638 | elif key in FLOAT: 639 | value = float(value) 640 | elif key in STRING: 641 | value = str(value) 642 | elif key in FLOAT_LIST: 643 | value = [float(val) for val in value.split()] 644 | elif key in INTEGER_LIST: 645 | value = [int(val) for val in value.split()] 646 | elif key in UNDEFINED: 647 | print(item) 648 | else: 649 | print('Unknown item:', item) 650 | except Exception as e: 651 | print(e) 652 | print(item) 653 | print(key, value) 654 | raise 655 | 656 | d[key] = value 657 | 658 | if sequence >= 0: 659 | d['sequence'] = sequence 660 | if kind: 661 | d['kind'] = kind 662 | 663 | return d 664 | 665 | 666 | def block2nav(block: list, tag: str = None, data_drc: str = None) -> 'NavItem': 667 | """Takes a text block from a SerialEM .nav file and converts it into a 668 | instance of `NavItem` or `MapItem`""" 669 | d = block2dict(block) 670 | kind = d['Type'] 671 | 672 | if kind == 2: 673 | ret = MapItem(d, tag=tag, data_drc=data_drc) 674 | else: 675 | ret = NavItem(d, tag=tag) 676 | 677 | return ret 678 | 679 | 680 | def read_nav_file(fn: str, acquire_only: bool = False) -> list: 681 | """Reads a SerialEM .nav file and returns a list of dictionaries containing 682 | nav item data. 683 | 684 | acquire_only: bool 685 | read only files with the Acquire tag set 686 | """ 687 | 688 | # https://regex101.com/ 689 | patt_match = re.compile(r'\[Item\s?=\s?([a-zA-Z0-9_-]*)\]') 690 | 691 | capture = False 692 | block = [] 693 | items = [] 694 | tag = '' 695 | drc = Path(fn).absolute().parent 696 | 697 | f = open(fn, 'r') 698 | for line in f: 699 | line = line.strip() 700 | if not line: 701 | continue 702 | 703 | m = re.match(patt_match, line) 704 | 705 | if m: 706 | if block: 707 | items.append(block2nav(block, tag=tag, data_drc=drc)) 708 | 709 | # prep for next block 710 | tag = m.groups()[0] 711 | block = [] 712 | capture = True 713 | elif capture: 714 | block.append(line) 715 | else: 716 | if line.startswith('AdocVersion'): 717 | pass 718 | elif line.startswith('LastSavedAs'): 719 | pass 720 | else: 721 | print(line) 722 | 723 | items.append(block2nav(block, tag=tag, data_drc=drc)) 724 | 725 | if acquire_only: 726 | items = [item for item in items if item.Acquire] 727 | 728 | # associate markers with map items 729 | map_items = (item for item in items if item.kind == 'Map') 730 | markers = (item for item in items if item.kind == 'Marker') 731 | 732 | d = defaultdict(list) 733 | 734 | for marker in markers: 735 | d[marker.DrawnID].append(marker) 736 | 737 | for map_item in map_items: 738 | markers = d[map_item.MapID] 739 | map_item.update_markers(*markers) 740 | 741 | return items 742 | 743 | 744 | def write_nav_file(fn: str, *items, mode='w') -> None: 745 | """Write list of nav items to a navigator file with filename `fn` to be 746 | read by SerialEM. 747 | 748 | `items` must be a list of NavItem / MapItem objects 749 | 750 | mode can be "w" to write a new file, or "a" to append 751 | items to an existing file 752 | """ 753 | f = open(fn, mode) if fn else None 754 | version = '2.00' 755 | 756 | if mode == 'w': 757 | print(f'AdocVersion = {version}', file=f) 758 | print(f'LastSavedAs = {fn}', file=f) 759 | print('', file=f) 760 | 761 | for item in items: 762 | print(item.to_string(), file=f) 763 | 764 | 765 | def read_mdoc_file(fn: str, only_kind: str = None) -> list: 766 | """Reads a SerialEM .mdoc file and returns a list of dictionaries 767 | containing supporting data. 768 | 769 | Parameters 770 | ---------- 771 | only_kind : str 772 | Return only items of this kind, i.e. ZValue or MontSection (case-insensitive) 773 | 774 | Returns: 775 | -------- 776 | List of dicts with header information from the .mdoc file 777 | """ 778 | 779 | # https://regex101.com/ 780 | patt_match = re.compile(r'\[([a-zA-Z]+)\s?=\s?([0-9]+)\]') 781 | 782 | capture = False 783 | block = [] 784 | items = [] 785 | kind = None 786 | sequence = 0 787 | 788 | f = open(fn, 'r') 789 | for line in f: 790 | line = line.strip() 791 | if not line: 792 | continue 793 | 794 | m = re.match(patt_match, line) 795 | 796 | if m: 797 | if block: 798 | items.append(block2dict(block, kind=kind, sequence=sequence)) 799 | 800 | # prep for next block 801 | kind = m.groups()[0] 802 | sequence = int(m.groups()[1]) 803 | 804 | block = [] 805 | capture = True 806 | elif capture: 807 | block.append(line) 808 | else: 809 | print(line) 810 | 811 | items.append(block2dict(block, kind=kind, sequence=sequence)) 812 | 813 | if only_kind: 814 | only_kind = only_kind.lower() 815 | items = [item for item in items if item['kind'].lower() == only_kind] 816 | 817 | return items 818 | 819 | 820 | if __name__ == '__main__': 821 | fn = 'C:/s/work_2019-06-26/navs2.nav' 822 | items = read_nav_file(fn) 823 | 824 | from IPython import embed 825 | embed() 826 | -------------------------------------------------------------------------------- /pyserialem/montage.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import lmfit 4 | import matplotlib.pyplot as plt 5 | import mrcfile 6 | import numpy as np 7 | from matplotlib import patches 8 | from scipy import ndimage 9 | from skimage import filters 10 | from skimage.registration import phase_cross_correlation 11 | from tqdm.auto import tqdm 12 | 13 | from .utils import bin_ndarray 14 | from .utils import translation 15 | 16 | 17 | DEFAULTS = { 18 | 'direction': 'downup', 19 | 'zigzag': True, 20 | 'flip': True, 21 | 'rot90': 3, 22 | 'flipud': True, 23 | 'fliplr': False, 24 | } 25 | 26 | 27 | def sorted_grid_indices(grid): 28 | """Sorts 2d the grid by its values, and returns an array with the indices 29 | (i.e. np.argsort on 2d arrays) https://stackoverflow.com/a/30577520.""" 30 | return np.dstack(np.unravel_index(np.argsort(grid.ravel()), grid.shape))[0] 31 | 32 | 33 | def find_threshold(values, half='lower') -> float: 34 | """Find largest discontinuity in the `lower` or `upper` half of the data. 35 | 36 | Parameters 37 | ---------- 38 | half : str 39 | Whether to use the `upper` or `lower` half of the data after sorting 40 | 41 | Returns 42 | ------- 43 | threshold : float 44 | Threshold splitting the largest discontinuity to segment data with 45 | """ 46 | x = np.array(sorted(values)) 47 | halfway = int(len(x) / 2) 48 | sel = x[:halfway] if half == 'lower' else x[halfway:] 49 | diff = np.diff(sel) 50 | i = diff.argmax() 51 | if half == 'upper': 52 | i += halfway 53 | thresh = (x[i] + x[i + 1]) / 2 54 | return thresh 55 | 56 | 57 | def weight_map(shape, method='block', plot=False): 58 | """Generate a weighting map for the given shape. 59 | 60 | Parameters 61 | ---------- 62 | shape : tuple 63 | Shape defines the 2 integers defining the shape of the image 64 | method : str 65 | Method to use `circle`/`block` 66 | plot : bool 67 | Plot the image 68 | 69 | Returns 70 | ------- 71 | weight : np.array 72 | Weight array with the given shape 73 | """ 74 | res_x, res_y = shape 75 | c_x = int(res_x / 2) - 0.5 76 | c_y = int(res_y / 2) - 0.5 77 | 78 | corner = (c_x**2 + c_y**2)**0.5 79 | 80 | a, b = np.meshgrid(np.arange(-c_x, c_x + 1), np.arange(-c_y, c_y + 1)) 81 | 82 | if method == 'block': 83 | a2 = c_x - np.abs(a) 84 | b2 = c_y - np.abs(b) 85 | 86 | d = np.min(np.stack((a2, b2)), axis=0) 87 | elif method == 'circle': 88 | d = corner - np.sqrt(a**2 + b**2) 89 | else: 90 | raise ValueError(f'No such method: `{method}`') 91 | 92 | # scale to 1 93 | d = d / d.max() 94 | 95 | if plot: 96 | plt.imshow(d) 97 | 98 | return d 99 | 100 | 101 | def make_grid(gridshape: tuple, 102 | direction: str = 'updown', 103 | zigzag: bool = True, 104 | flip: bool = False, 105 | ) -> 'np.array': 106 | """Defines the grid montage collection scheme. 107 | 108 | Parameters 109 | ---------- 110 | gridshape : tuple(int, int) 111 | Defines the shape of the grid 112 | direction : str 113 | Defines the direction of data collection starting from the top (lr, rl) 114 | or left-hand (ud, du) side `updown`, `downup`, `leftright`, `rightleft` 115 | zigzag : bool 116 | Defines if the data has been collected in a zigzag manner 117 | flip : bool 118 | Flip around the vertical (lr, rl) or horizontal (ud, du) axis, i.e. start from the 119 | botton (lr, rl) or right-hand (ud, du) side. 120 | 121 | Returns 122 | ------- 123 | np.array 124 | """ 125 | nx, ny = gridshape 126 | grid = np.arange(nx * ny).reshape(gridshape) 127 | 128 | if zigzag: 129 | grid[1::2] = np.fliplr(grid[1::2]) 130 | 131 | if direction == 'updown': 132 | if flip: 133 | grid = np.fliplr(grid) 134 | elif direction == 'downup': 135 | grid = np.flipud(grid) 136 | if flip: 137 | grid = np.fliplr(grid) 138 | elif direction == 'rightleft': 139 | grid = grid.T 140 | grid = np.fliplr(grid) 141 | if flip: 142 | grid = np.flipud(grid) 143 | elif direction == 'leftright': 144 | grid = grid.T 145 | if flip: 146 | grid = np.flipud(grid) 147 | else: 148 | raise ValueError(f'Invalid direction: {direction}') 149 | 150 | return grid 151 | 152 | 153 | def make_slices(overlap_x: int, overlap_y: int, shape=(512, 512), plot: bool = False) -> dict: 154 | """Make slices for left/right/top/bottom image. 155 | 156 | Parameters 157 | ---------- 158 | overlap_x/overlap_y : int 159 | Defines how far to set the overlap from the edge (number of pixels), 160 | this corresponds to the overlap between images 161 | shape : tuple: 162 | Define the shape of the image (only for plotting) 163 | plot : bool 164 | Plot the boundaries on blank images 165 | 166 | Returns 167 | ------- 168 | Dictionary with the slices for each side 169 | """ 170 | d = {} 171 | 172 | s_right = np.s_[:, -overlap_x:] 173 | s_left = np.s_[:, :overlap_x] 174 | s_top = np.s_[:overlap_y] 175 | s_bottom = np.s_[-overlap_y:] 176 | 177 | slices = (s_right, s_left, s_top, s_bottom) 178 | labels = ('right', 'left', 'top', 'bottom') 179 | 180 | d = dict(zip(labels, slices)) 181 | 182 | if plot: 183 | fig, axes = plt.subplots(2, 2, sharex=True, sharey=True) 184 | axes = axes.flatten() 185 | 186 | for ax, s_, label in zip(axes, slices, labels): 187 | arr = np.zeros(shape, dtype=int) 188 | arr[s_] = 1 189 | ax.imshow(arr) 190 | ax.set_title(label) 191 | 192 | plt.show() 193 | 194 | return d 195 | 196 | 197 | def define_directions(pairs: list): 198 | """Define pairwise relations between indices. 199 | 200 | Takes a list of index pair dicts, and determines on which side they 201 | are overlapping. The dictionary is updated with the keywords 202 | `side0`/`side1`. 203 | """ 204 | for pair in pairs: 205 | i0, j0 = pair['idx0'] 206 | i1, j1 = pair['idx1'] 207 | 208 | # checked 21-11-2019 for 'leftright' config 209 | if j0 == j1: 210 | if i1 > i0: 211 | side0, side1 = 'bottom', 'top' 212 | else: 213 | side0, side1 = 'top', 'bottom' 214 | else: 215 | if j1 > j0: 216 | side0, side1 = 'right', 'left' 217 | else: 218 | side0, side1 = 'left', 'right' 219 | 220 | # print(i0, j0, i1, j1, side0, side1) 221 | 222 | pair['side0'] = side0 223 | pair['side1'] = side1 224 | 225 | return pairs 226 | 227 | 228 | def define_pairs(grid: 'np.ndarray'): 229 | """Take a sequence grid and return all pairs of neighbours. 230 | 231 | Returns a list of dictionaries containing the indices of the pairs 232 | (neighbouring only), and the corresponding sequence numbers 233 | (corresponding to the image array) 234 | """ 235 | nx, ny = grid.shape 236 | 237 | footprint = np.array([[0, 1, 0], 238 | [1, 0, 1], 239 | [0, 1, 0]]) 240 | 241 | shape = np.array(footprint.shape) 242 | assert shape[0] == shape[1], 'Axes must be equal' 243 | assert shape[0] % 2 == 1, 'Axis length must be odd' 244 | center = shape // 2 245 | 246 | connected = np.argwhere(footprint == 1) - center 247 | 248 | pairs = [] 249 | 250 | for idx0, i0 in np.ndenumerate(grid): 251 | neighbours = connected + idx0 252 | 253 | for neighbour in neighbours: 254 | neighbour = tuple(neighbour) 255 | if neighbour[0] < 0 or neighbour[0] >= nx: 256 | pass 257 | elif neighbour[1] < 0 or neighbour[1] >= ny: 258 | pass 259 | else: 260 | assert i0 == grid[idx0] 261 | d = { 262 | 'seq0': grid[idx0], 263 | 'seq1': grid[neighbour], 264 | 'idx0': idx0, 265 | 'idx1': neighbour, 266 | } 267 | pairs.append(d) 268 | 269 | return pairs 270 | 271 | 272 | def disambiguate_shift(strip0, strip1, shift, verbose: bool = False) -> tuple: 273 | """Disambiguate the shifts obtained from cross correlation.""" 274 | shift_x, shift_y = shift 275 | 276 | best_sum = np.inf 277 | best_shift = shift 278 | 279 | for i in (-1, 1): 280 | for j in (-1, 1): 281 | new_shift = (i * shift_x, j * shift_y) 282 | strip1_offset = ndimage.shift(strip1, new_shift) 283 | offset = strip1_offset - strip0.astype(float) 284 | sum_score = np.abs(offset).sum() 285 | if verbose: 286 | print(f'{i:2d} {j:2d} -> {sum_score:10.0f} {new_shift}') 287 | if sum_score < best_sum: 288 | best_sum = sum_score 289 | best_shift = new_shift 290 | 291 | if verbose: 292 | print('Disambiguated shift:', best_shift) 293 | 294 | return best_shift 295 | 296 | 297 | def plot_images(im0, im1, seq0, seq1, side0, side1, idx0, idx1): 298 | fig, axes = plt.subplots(ncols=2, figsize=(6, 3)) 299 | ax0, ax1 = axes.flatten() 300 | 301 | ax0.imshow(im0) 302 | ax0.set_title(f'{seq0} {idx0} {side0}') 303 | ax0.set_axis_off() 304 | ax1.imshow(im1) 305 | ax1.set_title(f'{seq1} {idx1} {side1}') 306 | ax1.set_axis_off() 307 | plt.tight_layout() 308 | plt.show() 309 | 310 | 311 | def plot_fft(strip0, strip1, shift, fft, side0, side1): 312 | fig, axes = plt.subplots(nrows=4, figsize=(8, 5)) 313 | axes = axes.flatten() 314 | for ax in axes: 315 | ax.set_axis_off() 316 | ax0, ax1, ax2, ax3 = axes 317 | 318 | assert strip0.shape == strip1.shape, f'Shapes do not match, strip1: {strip1.shape} strip2: {strip2.shape}' 319 | shape = strip0.shape 320 | 321 | if shape[0] > shape[1]: 322 | strip0 = strip0.T 323 | strip1 = strip1.T 324 | fft = fft.T 325 | t1, t0 = shift 326 | else: 327 | t0, t1 = shift 328 | 329 | # Show difference 330 | strip1_shifted = ndimage.shift(strip1, (t0, t1)) 331 | difference = strip1_shifted - strip0.astype(float) 332 | 333 | ax0.imshow(strip0, interpolation='nearest') 334 | ax0.set_title(f'{side0}') 335 | ax1.imshow(strip1, interpolation='nearest') 336 | ax1.set_title(f'{side1}') 337 | ax2.imshow(difference, interpolation='nearest') 338 | ax2.set_title(f'Abs(Difference) - Shift: {t0} {t1}') 339 | ax3.imshow(fft, vmin=np.percentile(fft, 90.0), vmax=np.percentile(fft, 99.99)) 340 | ax3.set_title(f'Cross correlation (max={fft.max():.4f})') 341 | 342 | if t0 < 0: 343 | t0 += shape[0] 344 | if t1 < 0: 345 | t1 += shape[0] 346 | 347 | ax3.scatter(t1, t0, color='red', marker='o', facecolor='none', s=100) 348 | ax3.set_xlim(0, fft.shape[1]) 349 | ax3.set_ylim(0, fft.shape[0]) 350 | 351 | plt.subplots_adjust(hspace=0.0) 352 | plt.show() 353 | 354 | 355 | def plot_shifted(im0, im1, difference_vector, seq0, seq1, idx0, idx1, res_x, res_y): 356 | blank = np.zeros((res_x * 2, res_y * 2), dtype=np.int32) 357 | 358 | center = np.array(blank.shape) // 2 359 | origin = np.array((res_x, res_y)) // 2 360 | 361 | coord0 = (center - difference_vector / 2 - origin).astype(int) 362 | coord1 = (center + difference_vector / 2 - origin).astype(int) 363 | 364 | print(f'Coord0: {coord0} | Coord1: {coord1}') 365 | 366 | txt = f'Difference vector\n#{seq0}:{idx0} -> #{seq1}:{idx1} = {difference_vector}' 367 | 368 | blank[coord0[0]: coord0[0] + res_x, coord0[1]: coord0[1] + res_y] += im0 369 | blank[coord1[0]: coord1[0] + res_x, coord1[1]: coord1[1] + res_y] += im1 370 | 371 | # Create a Rectangle patch 372 | rect0 = patches.Rectangle(coord0[::-1], res_x, res_y, linewidth=1, edgecolor='r', facecolor='none') 373 | rect1 = patches.Rectangle(coord1[::-1], res_x, res_y, linewidth=1, edgecolor='r', facecolor='none') 374 | 375 | fig, ax = plt.subplots(1, figsize=(8, 8)) 376 | 377 | # Add the patch to the Axes 378 | ax.add_patch(rect0) 379 | ax.add_patch(rect1) 380 | 381 | ax.imshow(blank) 382 | ax.set_title(txt) 383 | ax.set_axis_off() 384 | plt.show() 385 | 386 | 387 | class MontagePatch: 388 | """Simple class to calculate the bounding box for an image tile as part of 389 | a montage. 390 | 391 | Parameters 392 | ---------- 393 | image : np.ndarray [ m x n ] 394 | Original image tile 395 | coord : tuple 396 | Tuple with x/y coordinates of the patch (original size) location in the montage image 397 | binning : int 398 | Binning of the image patch 399 | """ 400 | 401 | def __init__(self, image, coord, binning: int = 1): 402 | super().__init__() 403 | self.binning = binning 404 | self.coord = coord 405 | self._image = image 406 | self._shape = image.shape 407 | 408 | @property 409 | def shape(self): 410 | res_x, res_y = self._shape 411 | shape = int(res_x / self.binning), int(res_y / self.binning) 412 | return shape 413 | 414 | @property 415 | def res_x(self): 416 | return self.shape[0] 417 | 418 | @property 419 | def res_y(self): 420 | return self.shape[1] 421 | 422 | @property 423 | def image(self): 424 | return bin_ndarray(self._image, self.shape) 425 | 426 | @property 427 | def x0(self): 428 | return int(self.coord[0] / self.binning) 429 | 430 | @property 431 | def x1(self): 432 | return self.x0 + self.shape[0] 433 | 434 | @property 435 | def y0(self): 436 | return int(self.coord[1] / self.binning) 437 | 438 | @property 439 | def y1(self): 440 | return self.y0 + self.shape[1] 441 | 442 | 443 | class Montage: 444 | """This class is used to stitch together a set of images to make a larger 445 | image. 446 | 447 | Parameters 448 | ---------- 449 | images : list 450 | List of images in numpy format 451 | gridspec : dict 452 | Dictionary defining the grid characteristics, directly passed to `make_grid`. 453 | overlap : float 454 | Defines the % of overlap between the images 455 | 456 | Based on Preibisch et al. (2009), Bioinformatics, 25(11):1463-1465 457 | http://dx.doi.org/10.1093/bioinformatics/btp184 458 | """ 459 | 460 | def __init__(self, 461 | images: list, 462 | gridspec: dict, 463 | overlap=0.1, 464 | **kwargs, 465 | ): 466 | super().__init__() 467 | 468 | self.images = images 469 | self.image_shape = images[0].shape 470 | self.gridspec = gridspec 471 | self.grid = make_grid(**gridspec) 472 | 473 | self.__dict__.update(**kwargs) 474 | 475 | res_x, res_y = self.image_shape 476 | self.overlap_x = int(res_x * overlap) 477 | self.overlap_y = int(res_y * overlap) 478 | 479 | @classmethod 480 | def from_serialem_mrc(cls, 481 | filename: str, 482 | gridshape: tuple, 483 | direction: str = DEFAULTS['direction'], 484 | zigzag: bool = DEFAULTS['zigzag'], 485 | flip: bool = DEFAULTS['flip'], 486 | image_rot90: int = DEFAULTS['rot90'], 487 | image_flipud: bool = DEFAULTS['flipud'], 488 | image_fliplr: bool = DEFAULTS['fliplr'], 489 | ): 490 | """Load a montage object from a SerialEM file image stack. The default 491 | parameters transform the images to data suitable for use with 492 | `pyserialem.Montage`. It makes no further assumptions about the way the 493 | data were collected. 494 | 495 | The parameters image_rot90/image_flipud/image_fliplr manipulate the images in this order, 496 | so they can look the same as when collected with Instamatic. This is necessary to use 497 | the stage calibration, which is not specified in the SerialEM mrc file. 498 | 499 | Use .set_stagematrix and .set_pixelsize to set the correct stagematrix and pixelsize. 500 | 501 | Parameters 502 | ---------- 503 | filename : str 504 | Filename of the mrc file to load. 505 | gridshape : tuple(2) 506 | Tuple describing the number of of x and y points in the montage grid. 507 | TODO: Find a way to get this from the .mrc/.mdoc 508 | direction : str 509 | Defines the direction of data collection 510 | zigzag : bool 511 | Defines if the data has been collected in a zigzag manner 512 | flip : bool 513 | Flip around the vertical (lr, rl) or horizontal (ud, du) axis, 514 | i.e. start from the botton (lr, rl) or right-hand (ud, du) side. 515 | image_rot90 : int 516 | Rotate the image by 90 degrees (clockwise) for this times, i.e., 517 | `image_rot90=3` rotates the image by 270 degrees. 518 | image_flipud: 519 | Flip the images around the horizintal axis. 520 | image_fliplr: 521 | Flip the images around the vertical axis. The 522 | 523 | 524 | Returns 525 | ------- 526 | Montage object constructed from the given images 527 | """ 528 | from pyserialem import read_mdoc_file 529 | filename = str(filename) # in case of Path object 530 | 531 | gm = mrcfile.open(filename) 532 | images = gm.data 533 | 534 | mdoc = read_mdoc_file(filename + '.mdoc', only_kind='zvalue') 535 | assert len(mdoc) == len(images) 536 | 537 | gridspec = { 538 | 'gridshape': gridshape, 539 | 'direction': direction, 540 | 'zigzag': zigzag, 541 | 'flip': flip, 542 | } 543 | 544 | # Rotate the images so they are in the same orientation as those from Instamatic 545 | if image_rot90: 546 | images = [np.rot90(image, k=image_rot90) for image in images] 547 | if image_flipud: 548 | images = [np.flipud(image) for image in images] 549 | if image_fliplr: 550 | images = [np.fliplr(image) for image in images] 551 | 552 | kwargs = { 553 | 'stagecoords': np.array([d['StagePosition'] for d in mdoc]) * 1000, # um->nm 554 | 'magnifiation': mdoc[-1]['Magnification'], 555 | 'image_binning': mdoc[-1]['Binning'], 556 | 'software': 'serialem', 557 | 'abs_mag_index': mdoc[-1]['MagIndex'], 558 | 'mdoc': mdoc, 559 | 'filename': filename, 560 | } 561 | 562 | m = cls(images=images, gridspec=gridspec, **kwargs) 563 | 564 | c1 = np.array([d['PieceCoordinates'][0:2] for d in mdoc]) 565 | 566 | def convert_coords(c, 567 | rot90=image_rot90, 568 | flipud=image_flipud, 569 | fliplr=image_fliplr, 570 | ): 571 | # SerialEM uses a different convention for X/Y 572 | c = np.fliplr(c) 573 | 574 | angle = np.radians(image_rot90 * 90) 575 | R = np.array([np.cos(angle), -np.sin(angle), np.sin(angle), np.cos(angle)]).reshape(2, 2) 576 | 577 | c = np.dot(c, R) 578 | 579 | if flipud: 580 | c[:, 1] *= -1 581 | if fliplr: 582 | c[:, 0] *= -1 583 | 584 | c -= c.min(axis=0) 585 | 586 | return c 587 | 588 | # Apparently, SerialEM can save one or the other or both 589 | # prefer APCVS over APC and move on 590 | for key in 'AlignedPieceCoordsVS', 'AlignedPieceCoords': 591 | if key in mdoc[0]: 592 | c2 = np.array([d[key][0:2] for d in mdoc]) 593 | break 594 | 595 | m.coords = convert_coords(c1) 596 | m.optimized_coords = convert_coords(c2) 597 | 598 | return m 599 | 600 | def update_gridspec(self, **gridspec): 601 | """Update the grid specification.""" 602 | self.gridspec.update(gridspec) 603 | self.grid = make_grid(**self.gridspec) 604 | 605 | def set_pixelsize(self, pixelsize: float): 606 | """Set the unbinned pixelsize in nanometer/pixel.""" 607 | self.pixelsize = pixelsize * self.image_binning 608 | 609 | def set_stagematrix(self, stagematrix: 'np.array[2,2]'): 610 | """Set the unbinned stage matrix calibration matrix.""" 611 | self.stagematrix = stagematrix * self.image_binning 612 | 613 | def set_stagematrix_from_serialem_calib(self, StageToCameraMatrix: str): 614 | """Set the stagematrix directly from the SerialEM calibration. 615 | 616 | Copy the line from `SerialEMcalibrations.txt` 617 | 618 | Looks something like: 619 | `StageToCameraMatrix 10 0 8.797544 0.052175 0.239726 8.460119 0.741238 100` 620 | 621 | Where the last number is the magnification. Also estimates the `pixelsize`. 622 | """ 623 | inp = StageToCameraMatrix.split()[3:7] 624 | values = [float(val) for val in inp] 625 | stagematrix_inv = np.array(values).reshape(2, 2) 626 | stagematrix = np.linalg.inv(stagematrix_inv) * 1000 627 | 628 | self.set_stagematrix(stagematrix) 629 | 630 | pixelsize = np.abs(stagematrix.trace() / 2) # estimate pixelsize from stagematrix 631 | self.set_pixelsize(pixelsize) 632 | 633 | return self.stagematrix 634 | 635 | def get_difference_vector(self, 636 | idx0: int, 637 | idx1: int, 638 | shift: list, 639 | overlap_k: float = 1.0, 640 | verbose: bool = False, 641 | ) -> list: 642 | """Calculate the pixel distance between 2 images using the calculate 643 | pixel shift from cross correlation. 644 | 645 | Parameters 646 | ---------- 647 | idx0, idx1 : int 648 | Grid coordinate of im0 and im0, defining their relative position 649 | shift : list 650 | The offset between the 2 strips of the images used for cross correlation 651 | overlap_k : float 652 | Extend the overlap by this factor, may help with the cross correlation 653 | For example, if the overlap is 50 pixels, `overlap_k=1.5` will extend the 654 | strips used for cross correlation to 75 pixels. 655 | 656 | Returns 657 | ------- 658 | difference_vector : np.array[1,2] 659 | Vector describing the pixel offset between the 2 images 660 | """ 661 | res_x, res_y = self.image_shape 662 | overlap_x = int(self.overlap_x * overlap_k) 663 | overlap_y = int(self.overlap_y * overlap_k) 664 | 665 | vect = np.array(idx1) - np.array(idx0) 666 | vect = vect * np.array((res_x - overlap_x, res_y - overlap_y)) 667 | 668 | difference_vector = vect + shift 669 | 670 | if verbose: 671 | print(f'Vector from indices: {vect}') 672 | print(f'Shift: {shift}') 673 | print(f'Difference vector: {difference_vector}') 674 | 675 | return difference_vector 676 | 677 | def calculate_difference_vectors(self, 678 | threshold: float = 'auto', 679 | overlap_k: float = 1.0, 680 | max_shift: int = 200, 681 | method: str = 'skimage', 682 | segment: bool = False, 683 | plot: bool = False, 684 | verbose: bool = False, 685 | ) -> dict: 686 | """Get the difference vectors between the neighbouring images The 687 | images are overlapping by some amount defined using `overlap`. These 688 | strips are compared with cross correlation to calculate the shift 689 | offset between the images. 690 | 691 | Parameters 692 | ---------- 693 | threshold : float 694 | Lower for the cross correlation score to accept a shift or not 695 | If a shift is not accepted, the shift is set to (0, 0). 696 | Use the value 'auto' to automatically determine the threshold value. 697 | The threshold can be visualized using `.plot_fft_scores()`. 698 | overlap_k : float 699 | Extend the overlap by this factor, may help with the cross correlation 700 | For example, if the overlap is 50 pixels, `overlap_k=1.5` will extend the 701 | strips used for cross correlation to 75 pixels. 702 | max_shift : int 703 | Maximum pixel shift for difference vector to be accepted. 704 | segment : bool 705 | Segment the image using otsu's method before cross correlation. This improves 706 | the contrast for registration. 707 | method : str 708 | Set to skimage to use the cross correlation function from scikit-image. Seems 709 | to work better than the default in some cases. 710 | verbose : bool 711 | Be more verbose 712 | 713 | Returns 714 | ------- 715 | difference_vectors : dict 716 | Dictionary with the pairwise shifts between the neighbouring 717 | images 718 | """ 719 | grid = self.grid 720 | res_x, res_y = self.image_shape 721 | images = self.images 722 | 723 | overlap_x = int(self.overlap_x * overlap_k) 724 | overlap_y = int(self.overlap_y * overlap_k) 725 | 726 | pairs = define_pairs(grid) 727 | pairs = define_directions(pairs) 728 | 729 | self.pairs = pairs 730 | 731 | slices = make_slices(overlap_x, overlap_y) 732 | 733 | self.slices = slices 734 | 735 | results = {} 736 | 737 | for i, pair in enumerate(tqdm(pairs)): 738 | seq0 = pair['seq0'] 739 | seq1 = pair['seq1'] 740 | 741 | side0 = pair['side0'] 742 | side1 = pair['side1'] 743 | idx0 = pair['idx0'] 744 | idx1 = pair['idx1'] 745 | im0 = images[seq0] 746 | im1 = images[seq1] 747 | 748 | if plot and False: 749 | plot_images(im0, im1, seq0, seq1, side0, side1, idx0, idx1) 750 | 751 | # If the pair of images has already been compared, copy that result instead 752 | if (seq1, seq0) in results: 753 | if verbose: 754 | print(f'{seq0:3d}{seq1:3d} | copy') 755 | result = results[seq1, seq0] 756 | shift = -result['shift'] 757 | score = result['fft_score'] 758 | else: 759 | if verbose: 760 | print(f'{seq0:3d}{seq1:3d} | fft') 761 | strip0 = im0[slices[side0]] 762 | strip1 = im1[slices[side1]] 763 | 764 | if segment: 765 | t0 = filters.threshold_otsu(strip0) 766 | t1 = filters.threshold_otsu(strip1) 767 | strip0 = strip0 > t0 768 | strip1 = strip1 > t1 769 | # print(f"Thresholds: {t1} {t1}") 770 | 771 | if method == 'skimage': # method = skimage.registration.phase_cross_correlation 772 | shift, error, phasediff = phase_cross_correlation(strip0, strip1, return_error=True) 773 | fft = np.ones_like(strip0) 774 | score = 1 - error**0.5 775 | else: 776 | shift, fft = translation(strip0, strip1, return_fft=True) 777 | score = fft.max() 778 | 779 | if plot: 780 | plot_fft(strip0, strip1, shift, fft, side0, side1) 781 | 782 | shift = np.array(shift) 783 | 784 | results[seq0, seq1] = { 785 | 'shift': shift, 786 | 'idx0': idx0, 787 | 'idx1': idx1, 788 | 'overlap_k': overlap_k, 789 | 'fft_score': np.nan_to_num(score, nan=0.0), 790 | } 791 | 792 | # if plot: 793 | # plot_shifted(im0, im1, difference_vector, seq0, seq1, idx0, idx1, res_x, res_y) 794 | 795 | self.raw_difference_vectors = results 796 | 797 | difference_vectors = self.filter_difference_vectors(threshold=threshold, 798 | verbose=verbose, 799 | max_shift=max_shift) 800 | 801 | self.difference_vectors = difference_vectors 802 | self.weights = {k: v['fft_score'] for k, v in results.items()} 803 | 804 | return difference_vectors 805 | 806 | def filter_difference_vectors(self, 807 | threshold: float = 'auto', 808 | max_shift: int = 200, 809 | verbose: bool = True, 810 | plot: bool = True, 811 | ) -> dict: 812 | """Filter the raw difference vectors based on their fft scores. 813 | 814 | Parameters 815 | ---------- 816 | threshold : float 817 | Lower for the cross correlation score to accept a shift or not 818 | If a shift is not accepted, the shift is set to (0, 0). 819 | Use the value 'auto' to automatically determine the threshold value. 820 | The threshold can be visualized using `.plot_fft_scores()`. 821 | max_shift : int 822 | Maximum pixel shift for difference vector to be accepted. 823 | verbose : bool 824 | Be more verbose 825 | plot : bool 826 | Plot the difference vectors 827 | """ 828 | results = self.raw_difference_vectors 829 | 830 | if threshold == 'auto': 831 | scores = [item['fft_score'] for item in results.values()] 832 | threshold = find_threshold(scores) 833 | 834 | self.fft_threshold = threshold 835 | 836 | out = {} 837 | for i, (key, item) in enumerate(results.items()): 838 | score = item['fft_score'] 839 | seq0, seq1 = key 840 | idx0 = item['idx0'] 841 | idx1 = item['idx1'] 842 | overlap_k = item['overlap_k'] 843 | shift = item['shift'] 844 | include = False 845 | 846 | if score < threshold: 847 | new_shift = np.array((0, 0)) 848 | msg = '-> Below threshold!' 849 | elif np.linalg.norm(shift) > max_shift: 850 | new_shift = np.array(0.0) 851 | msg = '-> Too large!' 852 | else: 853 | new_shift = item['shift'] 854 | msg = '-> :-)' 855 | include = True 856 | 857 | if verbose: 858 | t0, t1 = shift 859 | print(f'Pair {seq0:2d}:{idx0} - {seq1:2d}:{idx1} -> S: {score:.4f} -> Shift: {t0:4} {t1:4} {msg}') 860 | 861 | if include: 862 | out[seq0, seq1] = self.get_difference_vector(idx0, 863 | idx1, 864 | new_shift, 865 | overlap_k=overlap_k, 866 | verbose=False) 867 | 868 | return out 869 | 870 | def plot_shifts(self) -> None: 871 | """Plot the pixel shifts from the cross correlation.""" 872 | shifts = np.array([item['shift'] for item in self.raw_difference_vectors.values()]) 873 | scores = np.array([item['fft_score'] for item in self.raw_difference_vectors.values()]) 874 | t0, t1 = shifts.T 875 | plt.scatter(t0, t1, c=scores, marker='+') 876 | plt.xlabel('Shift X (px)') 877 | plt.ylabel('Shift Y (px)') 878 | plt.title('Pixel shifts (color = FFT score)') 879 | plt.axis('equal') 880 | plt.colorbar() 881 | plt.show() 882 | 883 | def plot_fft_scores(self) -> None: 884 | """Plot the distribution of fft scores for the cross correlation.""" 885 | scores = [item['fft_score'] for item in self.raw_difference_vectors.values()] 886 | shifts = np.array([item['shift'] for item in self.raw_difference_vectors.values()]) 887 | amplitudes = np.linalg.norm(shifts, axis=1) 888 | auto_thresh = find_threshold(scores) 889 | used_thresh = self.fft_threshold 890 | plt.axhline(auto_thresh, lw=0.5, color='red', label=f'Suggested threshold={auto_thresh:.4f}') 891 | plt.axhline(used_thresh, lw=0.5, color='green', label=f'Actual threshold={used_thresh:.4f}') 892 | plt.scatter(np.arange(len(scores)), sorted(scores), c=amplitudes, marker='.') 893 | plt.colorbar() 894 | plt.title('FFT scores (color = pixel shift)') 895 | plt.xlabel('Index') 896 | plt.ylabel('Score') 897 | plt.legend() 898 | plt.show() 899 | 900 | def calculate_montage_coords(self) -> list: 901 | """Get the coordinates for each section based on the gridspec only (not 902 | optimized) 903 | 904 | Returns 905 | ------- 906 | coords : np.array[-1, 2] 907 | Coordinates for each section in the montage map 908 | """ 909 | 910 | res_x, res_y = self.image_shape 911 | grid = self.grid 912 | 913 | overlap_x = self.overlap_x 914 | overlap_y = self.overlap_y 915 | 916 | # make starting values 917 | vect = np.array((res_x - overlap_x, res_y - overlap_y)) 918 | vects = [] 919 | 920 | for i, idx in enumerate(sorted_grid_indices(grid)): 921 | x0, y0 = vect * idx 922 | vects.append((x0, y0)) 923 | 924 | vects = np.array(vects) 925 | 926 | self.coords = vects 927 | 928 | return vects 929 | 930 | def optimize_montage_coords(self, 931 | method: str = 'leastsq', 932 | skip: tuple = (), 933 | verbose: bool = False, 934 | plot: bool = False, 935 | ) -> list: 936 | """Use the difference vectors between each pair of images to calculate 937 | the optimal coordinates for each section using least-squares 938 | minimization. 939 | 940 | Parameters 941 | ---------- 942 | method : str 943 | Least-squares minimization method to use (lmfit) 944 | skip : tuple 945 | List of integers of frames to ignore 946 | verbose : bool 947 | Be more verbose 948 | plot : bool 949 | Plot the original and optimized pixel coordinates 950 | 951 | Returns 952 | ------- 953 | coords : np.array[-1, 2] 954 | Optimized coordinates for each section in the montage map 955 | """ 956 | if not hasattr(self, 'coords'): 957 | self.calculate_montage_coords() 958 | vects = self.coords 959 | 960 | difference_vectors = self.difference_vectors 961 | weights = self.weights 962 | 963 | res_x, res_y = self.image_shape 964 | grid = self.grid 965 | grid_x, grid_y = grid.shape 966 | n_gridpoints = grid_x * grid_y 967 | 968 | # determine which frames items have neighours 969 | has_neighbours = {i for key in difference_vectors.keys() for i in key} 970 | 971 | # setup parameters 972 | params = lmfit.Parameters() 973 | 974 | middle_i = int(n_gridpoints / 2) # Find index of middlemost item 975 | for i, row in enumerate(vects): 976 | if i in skip: 977 | vary = False 978 | elif i not in has_neighbours: 979 | vary = False 980 | else: 981 | vary = (i != middle_i) # anchor on middle frame 982 | params.add(f'C{i}{0}', value=row[0], vary=vary, min=row[0] - res_x / 2, max=row[0] + res_x / 2) 983 | params.add(f'C{i}{1}', value=row[1], vary=vary, min=row[1] - res_y / 2, max=row[1] + res_y / 2) 984 | 985 | def obj_func(params, diff_vects, weights): 986 | V = np.array([v.value for k, v in params.items() if k.startswith('C')]).reshape(-1, 2) 987 | 988 | # Minimization function from 2.2 989 | out = [] 990 | for i, j in diff_vects.keys(): 991 | if i in skip: 992 | continue 993 | if j in skip: 994 | continue 995 | 996 | diffij = diff_vects[i, j] 997 | x = V[j] - V[i] - diffij 998 | weight = weights[i, j] 999 | out.append(x * weight) 1000 | 1001 | return np.array(out) 1002 | 1003 | args = (difference_vectors, weights) 1004 | res = lmfit.minimize(obj_func, params, args=args, method=method) 1005 | 1006 | lmfit.report_fit(res, show_correl=verbose, min_correl=0.8) 1007 | 1008 | params = res.params 1009 | Vn = np.array([v.value for k, v in params.items() if k.startswith('C')]).reshape(-1, 2) 1010 | 1011 | offset = min(Vn[:, 0]), min(Vn[:, 1]) 1012 | coords = Vn - offset 1013 | 1014 | self.optimized_coords = coords 1015 | 1016 | if plot: 1017 | # center on average coordinate to better show displacement 1018 | c1 = self.coords - np.mean(self.coords, axis=0) 1019 | c2 = self.optimized_coords - np.mean(self.optimized_coords, axis=0) 1020 | plt.title(f'Shifts from minimization (`{method}`)\nCentered on average position') 1021 | plt.scatter(*c1.T, label='Original', marker='+') 1022 | plt.scatter(*c2.T, label='Optimized', marker='+') 1023 | # for (x1,y1), (x2, y2) in zip(m.coords, m.optimized_coords): 1024 | # arrow = plt.arrow(x1, x2, x2-x1, y2-y1) 1025 | plt.axis('equal') 1026 | plt.legend() 1027 | 1028 | return coords 1029 | 1030 | def _montage_patches(self, coords, binning=1): 1031 | montage_patches = [] 1032 | for i, coord in enumerate(coords): 1033 | image = self.images[i] 1034 | patch = MontagePatch(image, coord, binning=binning) 1035 | montage_patches.append(patch) 1036 | return montage_patches 1037 | 1038 | def stitch(self, 1039 | method: str = None, 1040 | binning: int = 1, 1041 | optimized: bool = True): 1042 | """Stitch the images together using the given list of pixel coordinates 1043 | for each section. 1044 | 1045 | Parameters 1046 | ---------- 1047 | method : str 1048 | Choices: [None, 'weighted', 'average'] 1049 | With `weighted`, the intensity contribution is weighted by 1050 | the distance from the center of the image. With 'average', 1051 | the images are averaged, and 'None' simply places the patches 1052 | in sequential order, overwriting previous data. 1053 | binning : int 1054 | Bin the Montage image by this factor 1055 | optimized : bool 1056 | Use optimized coordinates if they are available [default = True] 1057 | 1058 | Return 1059 | ------ 1060 | stitched : np.array 1061 | Stitched image 1062 | """ 1063 | if optimized: 1064 | try: 1065 | coords = self.optimized_coords 1066 | except AttributeError: 1067 | coords = self.coords 1068 | else: 1069 | coords = self.coords 1070 | 1071 | grid = self.grid 1072 | images = self.images 1073 | nx, ny = grid.shape 1074 | res_x, res_y = self.image_shape 1075 | n_images = len(images) 1076 | 1077 | assert nx * ny == n_images, f'Number of images ({n_images}) does not match number of grid shape ({nx}x{ny})!' 1078 | 1079 | c = coords.astype(int) 1080 | stitched_x, stitched_y = c.max(axis=0) - c.min(axis=0) 1081 | stitched_x += res_x 1082 | stitched_y += res_y 1083 | 1084 | stitched = np.zeros((int(stitched_x / binning), 1085 | int(stitched_y / binning)), 1086 | dtype=np.float32) 1087 | 1088 | if method in ('average', 'weighted'): 1089 | n_images = np.zeros_like(stitched) 1090 | 1091 | if method == 'weighted': 1092 | weight = weight_map((int(res_x / binning), 1093 | int(res_y / binning)), 1094 | method='circle') 1095 | 1096 | montage_patches = self._montage_patches(coords, binning=binning) 1097 | for i, patch in enumerate(montage_patches): 1098 | im = patch.image 1099 | x0 = patch.x0 1100 | y0 = patch.y0 1101 | x1 = patch.x1 1102 | y1 = patch.y1 1103 | 1104 | if method == 'average': 1105 | stitched[x0:x1, y0:y1] += im 1106 | n_images[x0:x1, y0:y1] += 1 1107 | elif method == 'weighted': 1108 | stitched[x0:x1, y0:y1] += im * weight 1109 | n_images[x0:x1, y0:y1] += weight 1110 | else: 1111 | stitched[x0:x1, y0:y1] = im 1112 | 1113 | if method in ('average', 'weighted'): 1114 | n_images = np.where(n_images == 0, 1, n_images) 1115 | stitched /= n_images 1116 | 1117 | self.stitched = stitched 1118 | self.centers = coords + np.array((res_x, res_y)) / 2 1119 | self.stitched_binning = binning 1120 | self.montage_patches = montage_patches 1121 | 1122 | return stitched 1123 | 1124 | def plot(self, ax=None, vmax: int = None, labels: bool = True): 1125 | """Plots the stitched image. 1126 | 1127 | Parameters 1128 | ---------- 1129 | ax : matplotlib.Axis 1130 | Matplotlib axis to plot on. 1131 | """ 1132 | stitched = self.stitched 1133 | 1134 | if not ax: 1135 | fig, ax = plt.subplots(figsize=(10, 10)) 1136 | 1137 | grid = self.grid 1138 | indices = sorted_grid_indices(grid) 1139 | montage_patches = self.montage_patches 1140 | for i, patch in enumerate(montage_patches): 1141 | idx = indices[i] 1142 | txt = f'{i}\n{idx}' 1143 | 1144 | if labels: 1145 | # NOTE that y/x are flipped for display in matplotlib ONLY 1146 | ax.text((patch.y0 + patch.y1) / 2, 1147 | (patch.x0 + patch.x1) / 2, 1148 | txt, 1149 | color='red', 1150 | fontsize=18, 1151 | ha='center', 1152 | va='center', 1153 | ) 1154 | rect = patches.Rectangle([patch.y0, patch.x0], 1155 | patch.res_x, 1156 | patch.res_y, 1157 | linewidth=0.5, 1158 | edgecolor='r', 1159 | facecolor='none', 1160 | ) 1161 | ax.add_patch(rect) 1162 | 1163 | ax.imshow(stitched, vmax=vmax) 1164 | ax.set_title('Stitched image') 1165 | 1166 | if not ax: 1167 | plt.show() 1168 | else: 1169 | return ax 1170 | 1171 | def export(self, outfile: str = 'stitched.tiff') -> None: 1172 | """Export the stitched image to a tiff file. 1173 | 1174 | Parameters 1175 | ---------- 1176 | outfile : str 1177 | Name of the image file. 1178 | """ 1179 | raise NotImplementedError 1180 | 1181 | def to_nav(self, 1182 | fn: str = 'stitched.nav', 1183 | coords: list = None, 1184 | kind: str = 'pixel'): 1185 | """Write montage to a SerialEM .nav file. 1186 | NOTE: The stage coordinates in the .nav file are not reliable. 1187 | 1188 | Parameters 1189 | ---------- 1190 | coords : np.array 1191 | List of pixel / stagecoords 1192 | kind : str 1193 | Specify whether the coordinates are pixel or stage coordinates, 1194 | must be one of `pixel` or `stage`. 1195 | 1196 | Returns 1197 | ------- 1198 | map_item : `MapItem` 1199 | Map item corresponding to the stitched image. Any coords 1200 | specified are accessed as a dict of markers 1201 | through `map_item.markers`. 1202 | """ 1203 | from pyserialem import MapItem, write_nav_file 1204 | 1205 | stem = fn.rsplit('.', 1)[-1] 1206 | fn_mrc = stem + '.mrc' 1207 | f = mrcfile.new(fn_mrc, data=self.stitched, overwrite=True) 1208 | f.close() 1209 | 1210 | map_scale_mat = np.linalg.inv(self.stagematrix) 1211 | 1212 | d = { 1213 | 'StageXYZ': [0, 0, 0], 1214 | 'MapFile': fn_mrc, 1215 | 'MapSection': 0, 1216 | 'MapBinning': self.image_binning, 1217 | 'MapMagInd': self.abs_mag_index, 1218 | 'MapScaleMat': map_scale_mat.flatten().tolist(), 1219 | 'MapWidthHeight': self.stitched.shape, 1220 | } 1221 | 1222 | map_item = MapItem.from_dict(d) 1223 | 1224 | if coords is not None: 1225 | markers = map_item.add_marker_group(coords, kind=kind) 1226 | 1227 | write_nav_file(fn, map_item, *map_item.markers.values()) 1228 | 1229 | return map_item 1230 | 1231 | def pixel_to_stagecoord(self, 1232 | px_coord: tuple, 1233 | stagematrix=None, 1234 | plot=False, 1235 | ) -> 'np.array': 1236 | """Takes a pixel coordinate and transforms it into a stage coordinate. 1237 | 1238 | Parameters 1239 | ---------- 1240 | stage_coords : np.array (nx2) 1241 | List of stage coordinates in nm. 1242 | stagematrix : np.array (2x2) 1243 | Stage matrix to convert from pixel to stage coordinates 1244 | plot : bool 1245 | Visualize the pixelcoordinates on the stitched images 1246 | 1247 | Returns 1248 | ------- 1249 | np.array (nx2) 1250 | Stage coordinates (nm) corresponding to pixel coordinates 1251 | given in the stitched image. 1252 | """ 1253 | if stagematrix is None: 1254 | stagematrix = self.stagematrix 1255 | 1256 | stagematrix = self.stagematrix 1257 | 1258 | px_coord = np.array(px_coord) * self.stitched_binning 1259 | cx, cy = np.dot(np.array(self.image_shape) / 2, stagematrix) 1260 | 1261 | diffs = np.linalg.norm((self.centers - px_coord), axis=1) 1262 | j = np.argmin(diffs) 1263 | 1264 | image_pixel_coord = self.coords[j] 1265 | image_stage_coord = self.stagecoords[j] 1266 | 1267 | tx, ty = np.dot(px_coord - image_pixel_coord, stagematrix) 1268 | 1269 | stage_coord = np.array((tx, ty)) + image_stage_coord - np.array((cx, cy)) 1270 | stage_coord = stage_coord.astype(int) # round to integer 1271 | 1272 | if plot: 1273 | img = self.images[j] 1274 | plt.imshow(img) 1275 | plot_x, plot_y = px_coord - image_pixel_coord 1276 | plt.scatter(plot_y, plot_x, color='red') 1277 | plt.text(plot_y, plot_x, ' P', fontdict={'color': 'red', 'size': 20}) 1278 | plt.title(f'Image coord: {image_stage_coord}\nP: {stage_coord}\nLinked to: {j}') 1279 | plt.show() 1280 | 1281 | return stage_coord 1282 | 1283 | def pixel_to_stagecoords(self, pixelcoords, stagematrix=None, plot: bool = False): 1284 | """Convert a list of pixel coordinates into stage coordinates. Uses 1285 | `.pixel_to_stagecoord` 1286 | 1287 | Parameters 1288 | ---------- 1289 | pixel_coords : np.array (nx2) 1290 | List of pixel coordinates. 1291 | stagematrix : np.array (2x2) 1292 | Stage matrix to convert from pixel to stage coordinates 1293 | plot : bool 1294 | Visualize the pixelcoordinates on the stitched images 1295 | 1296 | Returns 1297 | ------- 1298 | stage_coords : np.array (nx2) 1299 | stage coordinates corresponding to the given pixel coordinates on the stitched image. 1300 | """ 1301 | if stagematrix is None: 1302 | stagematrix = self.stagematrix 1303 | 1304 | f = self.pixel_to_stagecoord 1305 | stage_coords = np.array([f(px_coord, stagematrix=stagematrix) for px_coord in pixelcoords]) 1306 | 1307 | if plot: 1308 | plot_x, plot_y = stage_coords.T 1309 | plt.scatter(plot_x, plot_y, color='red', marker='.') 1310 | plt.title(f'Stage coordinates') 1311 | plt.xlabel('X (nm)') 1312 | plt.xlabel('Y (nm)') 1313 | plt.show() 1314 | 1315 | return stage_coords 1316 | 1317 | def stage_to_pixelcoord(self, 1318 | stage_coord: tuple, 1319 | stagematrix=None, 1320 | plot: bool = False, 1321 | ) -> 'np.array': 1322 | """Takes a stage coordinate and transforms it into a pixel coordinate. 1323 | 1324 | Note that this is not the inverse of `.pixel_to_stagecoord`, as this function 1325 | finds the closest image to 'hook onto', and calculates the pixel coordinate 1326 | with that image as the reference position. 1327 | 1328 | Parameters 1329 | ---------- 1330 | stage_coords : np.array (nx2) 1331 | List of stage coordinates in nm. 1332 | stagematrix : np.array (2x2) 1333 | Stage matrix to convert from pixel to stage coordinates 1334 | plot : bool 1335 | Visualize the pixelcoordinates on the stitched images 1336 | 1337 | Returns 1338 | ------- 1339 | px_cord : np.array (1x2) 1340 | Pixel coordinates corresponding to the stitched image. 1341 | """ 1342 | if stagematrix is None: 1343 | stagematrix = self.stagematrix 1344 | 1345 | mat = stagematrix 1346 | mati = np.linalg.inv(stagematrix) 1347 | 1348 | center_offset = np.dot(np.array(self.image_shape) / 2, mat) 1349 | stage_coord = np.array(stage_coord) 1350 | 1351 | diffs = np.linalg.norm((self.stagecoords - stage_coord), axis=1) 1352 | j = np.argmin(diffs) 1353 | 1354 | image_stage_coord = self.stagecoords[j] 1355 | image_pixel_coord = self.coords[j].copy() 1356 | 1357 | # The image pixel coordinate `image_pixel_coord` corresponds to the corner, 1358 | # but the stage coord `image_stage_coord` at the center of the image. 1359 | # `px_coord` is the relative offset added to the corner pixel coordiante of the image 1360 | px_coord = np.dot(stage_coord - image_stage_coord + center_offset, mati) + image_pixel_coord 1361 | 1362 | px_coord /= self.stitched_binning 1363 | px_coord = px_coord.astype(int) 1364 | 1365 | if plot: 1366 | plot_x, plot_y = px_coord 1367 | plt.imshow(self.stitched) 1368 | plt.scatter(plot_y, plot_x, color='red', marker='.') 1369 | plt.text(plot_y, plot_x, ' P', fontdict={'color': 'red', 'size': 20}) 1370 | plt.title(f'P: {px_coord}\nStage: {stage_coord}\nLinked to: {j}') 1371 | plt.show() 1372 | 1373 | return px_coord 1374 | 1375 | def stage_to_pixelcoords(self, 1376 | stage_coords: tuple, 1377 | stagematrix=None, 1378 | plot: bool = False, 1379 | ) -> 'np.array': 1380 | """Convert a list of stage coordinates into pixelcoordinates. Uses 1381 | `.stage_to_pixelcoord` 1382 | 1383 | Note that this is not the inverse of `.pixel_to_stagecoord`, as this function 1384 | finds the closest image to 'hook onto', and calculates the pixel coordinate 1385 | with that image as the reference position. 1386 | 1387 | Parameters 1388 | ---------- 1389 | stage_coords : np.array (nx2) 1390 | List of stage coordinates in nm. 1391 | stagematrix : np.array (2x2) 1392 | Stage matrix to convert from pixel to stage coordinates 1393 | plot : bool 1394 | Visualize the pixelcoordinates on the stitched images 1395 | 1396 | Returns 1397 | ------- 1398 | px_coords : np.array (nx2) 1399 | Pixel coordinates corresponding to the stitched image. 1400 | """ 1401 | f = self.stage_to_pixelcoord 1402 | px_coords = np.array([f(stage_coord, stagematrix=stagematrix) for stage_coord in stage_coords]) 1403 | 1404 | if plot: 1405 | plot_x, plot_y = px_coords.T 1406 | plt.imshow(self.stitched) 1407 | plt.scatter(plot_y, plot_x, color='red', marker='.') 1408 | plt.title(f'Pixel coordinates mapped on stitched image') 1409 | plt.show() 1410 | 1411 | return px_coords 1412 | 1413 | def find_holes(self, 1414 | diameter: float = None, 1415 | tolerance: float = 0.1, 1416 | pixelsize: float = None, 1417 | threshold: int = None, 1418 | plot: bool = False, 1419 | ) -> tuple: 1420 | """Find grid holes in the montage image. 1421 | 1422 | Parameters 1423 | ---------- 1424 | diameter : float 1425 | In nm, approximate diameter of squares/grid holes. If it 1426 | it is not specified, take the median diameter of all props. 1427 | tolerance : float 1428 | Tolerance in % how far the calculate diameter can be off 1429 | pixelsize : float 1430 | Unbinned tile pixelsize in nanometers 1431 | threshold: int 1432 | Threshold to use for segmentation. By default the `otsu` method 1433 | is used to find a suitable threshold. 1434 | plot : bool 1435 | Plot the segmentation results and coordinates using Matplotlib 1436 | 1437 | Returns 1438 | ------- 1439 | stagecoords : np.array, imagecoords : np.array 1440 | Return both he stage and imagecoords as numpy arrays 1441 | """ 1442 | from skimage import filters 1443 | from skimage import morphology 1444 | from skimage.measure import regionprops 1445 | 1446 | stitched = self.stitched 1447 | 1448 | if not threshold: 1449 | threshold = filters.threshold_otsu(stitched) 1450 | selem = morphology.disk(10) 1451 | seg = morphology.binary_closing(stitched > threshold, selem=selem) 1452 | 1453 | labeled, _ = ndimage.label(seg) 1454 | props = regionprops(labeled) 1455 | 1456 | stitched_binning = self.stitched_binning 1457 | 1458 | if not pixelsize: 1459 | pixelsize = self.pixelsize * stitched_binning 1460 | else: 1461 | pixelsize *= stitched_binning 1462 | 1463 | if diameter is None: 1464 | diameter = np.median([(prop.area ** 0.5) * pixelsize for prop in props]) 1465 | print(f'Diameter: {diameter:.0f} nm') 1466 | 1467 | max_val = tolerance * diameter 1468 | 1469 | stagecoords = [] 1470 | imagecoords = [] 1471 | 1472 | allds = [] # all diameters 1473 | selds = [] # selected diameters 1474 | 1475 | for prop in props: 1476 | x, y = prop.centroid 1477 | 1478 | d = (prop.area ** 0.5) * pixelsize 1479 | allds.append(d) 1480 | 1481 | if abs(d - diameter) < max_val: 1482 | stagecoord = self.pixel_to_stagecoord((x, y)) 1483 | stagecoords.append(stagecoord) 1484 | imagecoords.append((x, y)) 1485 | selds.append(d) 1486 | 1487 | stagecoords = np.array(stagecoords) 1488 | imagecoords = np.array(imagecoords) 1489 | 1490 | if plot: 1491 | fig, (ax0, ax1, ax2) = plt.subplots(ncols=3, figsize=(12, 4)) 1492 | 1493 | ax0.set_title(f'Segmentation (z>{threshold:.0f})') 1494 | ax0.imshow(seg) 1495 | 1496 | ax1.set_title('Image coords') 1497 | ax1.imshow(stitched) 1498 | 1499 | ax2.set_title('Stage coords') 1500 | 1501 | try: 1502 | plot_x, plot_y = np.array(imagecoords).T 1503 | ax1.scatter(plot_y, plot_x, marker='+', color='r') 1504 | except ValueError: 1505 | pass 1506 | 1507 | try: 1508 | plot_x, plot_y = np.array(stagecoords).T 1509 | ax2.scatter(plot_x / 1000, plot_y / 1000, marker='+') 1510 | ax2.scatter(0, 0, marker='+', color='red', label='Origin') 1511 | ax2.legend() 1512 | ax2.axis('equal') 1513 | ax2.set_xlabel('X (μm)') 1514 | ax2.set_ylabel('Y (μm)') 1515 | except ValueError: 1516 | pass 1517 | 1518 | prc = np.percentile 1519 | mdn = np.median 1520 | print(f'All hole diameters 50%: {mdn(allds):6.0f} | 5%: {prc(allds, 5):6.0f} | 95%: {prc(allds, 95):6.0f}') 1521 | if len(selds) > 0: 1522 | print(f'Selected hole diameter 50%: {mdn(selds):6.0f} | 5%: {prc(selds, 5):6.0f} | 95%: {prc(selds, 95):6.0f}') 1523 | else: 1524 | print(f'Selected hole diameter 50%: {"-":>6s} | 5%: {"-":>6s} | 95%: {"-":>6s}') 1525 | 1526 | self.feature_coords_stage = stagecoords 1527 | self.feature_coords_image = imagecoords 1528 | 1529 | return stagecoords, imagecoords 1530 | 1531 | 1532 | if __name__ == '__main__': 1533 | pass 1534 | --------------------------------------------------------------------------------