Repository: brisvag/blik (language: python)

Issue:
Documentation
We should add some basic documentation. We shoul be able to use jupyterbook, since we have some experience with it.


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/make_docs.yml b/.github/workflows/make_docs.yml
new file mode 100644
index 00000000..c6f931f3
--- /dev/null
+++ b/.github/workflows/make_docs.yml
@@ -0,0 +1,35 @@
+name: Make docs
+
+on:
+  push:
+    branches:
+      - master
+    tags:
+      - 'v[0-9]+.[0-9]+.[0-9]+'  # accept all tags of the format vX.X.X
+
+jobs:
+  deploy:
+    name: Make docs
+    runs-on: ubuntu-latest
+    if: github.repository == 'gutsche-lab/peepingtom'
+    steps:
+    - name: Checkout code
+      uses: actions/checkout@master
+    - name: Install Python
+      uses: actions/setup-python@v2
+      with:
+        python-version: 3.8
+    - name: Install Dependencies
+      working-directory: ./docs
+      run: |
+        python -m pip install --upgrade pip
+        python -m pip install -r requirements.txt
+    - name: Build the book
+      working-directory: ./docs
+      run: |
+        jupyter-book build .
+    - name: GitHub Pages
+      uses: peaceiris/actions-gh-pages@v3
+      with:
+        github_token: ${{ secrets.GITHUB_TOKEN }}
+        publish_dir: ./docs/_build/html
diff --git a/.github/workflows/publish_pypi.yml b/.github/workflows/publish_pypi.yml
index 5add72c9..c906789f 100644
--- a/.github/workflows/publish_pypi.yml
+++ b/.github/workflows/publish_pypi.yml
@@ -5,7 +5,7 @@ on:
     branches:
       - master
     tags:
-      - 'v*' # version tags (e.g: v0.0.1)
+      - 'v[0-9]+.[0-9]+.[0-9]+'  # accept all tags of the format vX.X.X
 
 jobs:
   deploy:
diff --git a/README.md b/README.md
index 3cd52123..522dc82d 100644
--- a/README.md
+++ b/README.md
@@ -1,12 +1,14 @@
+![logo](docs/images/logo.png)
+
 # PeepingTom
 
 ![Codecov branch](https://img.shields.io/codecov/c/github/gutsche-lab/peepingtom/master?label=codecov)
 ![PyPI](https://img.shields.io/pypi/v/peepingtom)
 ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/peepingtom)
 
-**PeepingTom** is a python tool for visualising and interacting with cryo-ET and subtomogram averaging data. It leverages the fast, multi-dimensional [napari viewer](https://github.com/napari/napari) and the scientific python stack.
+**PeepingTom** is a python tool for visualising and interacting with cryo-ET and subtomogram averaging data. It leverages the fast, multi-dimensional [napari viewer](https://napari.org) and the scientific python stack.
 
-**DISCLAIMER**: this package is in early development phase. Expect frequent bugs and crashes. Feel free to report them on the issue tracker.
+**DISCLAIMER**: this package is in early development phase. Expect frequent bugs and crashes. Please, report them on the issue tracker and ask if anything is unclear!
 
 ## Installation
 
@@ -14,45 +16,18 @@
 pip install peepingtom
 ```
 
-## Usage
+## Basic Usage
 
-From the command line, try:
+From the command line:
 ```bash
 peep /path/to.star /path/to/mrc/files/
 ```
-PeepingTom accepts any combination of files and directory, and will try find the right files and display them in napari. `peep` will also start an ipython shell with the following setup:
-```python
-import peepingtom as pt
-p = Peeper([your_data])
-```
 
-For more customizability (such as filtering) check out:
-```bash
-peep -h
-```
-
-Alternatively, use PeepingTom from within python directly (it's designed with ipython in mind):
+PeepingTom accepts any combination of files and directory, and will try find the right files and display them in napari. `peep` will also start an ipython shell with the following setup:
 ```python
 import peepingtom as pt
-
-p = pt.peep('path_to.mrc')
-p.show()
+peeper = Peeper([your_data])
+viewer = peeper.napari_viewer
 ```
 
-To get a closer view at all the `DataBlocks` contained in your `Peeper`:
-```python
-p.pprint()
-p.volumes()
-```
-
-## Tips
-To navigate nested attributes in `Peeper`, you can use the convenient `DispatchList`. Some methods of `Peeper` return this type of list, which is printed as `*[elements]`. Try:
-```python
-p.datablocks.name
-# *[name1, name2, name3, ...]
-```
-
-It works for method calls and setting values as well!
-```python
-p.napari_layers.visible = False
-```
+For more information, check out the help page with `peep -h` and [head over to the docs](https://gutsche-lab.github.io/peepingtom).
diff --git a/docs/_config.yml b/docs/_config.yml
new file mode 100644
index 00000000..96a82d8b
--- /dev/null
+++ b/docs/_config.yml
@@ -0,0 +1,66 @@
+# Book settings
+# Learn more at https://jupyterbook.org/customize/config.html
+title: PeepingTom
+logo: images/logo.png
+author: PeepingTom Team
+email: peepingtom_team@googlegroups.com
+copyright: "2020" # Copyright year to be placed in the footer
+only_build_toc_files: true
+
+# Force re-execution of notebooks on each build.
+# See https://jupyterbook.org/content/execute.html
+execute:
+  execute_notebooks: force
+
+# Information about where the book exists on the web
+repository:
+  url: https://github.com/gutsche-lab/peepingtom # Online location of your book
+  path_to_book: docs # Optional path to your book, relative to the repository root
+  branch: master # Which branch of the repository should be used when creating links (optional)
+
+# Add GitHub buttons to your book
+# See https://jupyterbook.org/customize/config.html#add-a-link-to-your-repository
+html:
+  use_issues_button: true
+  use_repository_button: true
+
+sphinx:
+  extra_extensions:
+    - sphinx.ext.viewcode
+    - sphinx.ext.autodoc
+    - sphinx.ext.intersphinx
+
+  config:
+    autodoc_default_options:
+        special-members: False
+        undoc-members: True
+        show-inheritance: True
+    intersphinx_mapping:
+      python:
+        - "https://docs.python.org/3"
+        - null
+      napari:
+        - "https://napari.org/docs/dev/"
+        - null
+    html_favicon: "images/favicon.ico"
+    html_theme: "sphinx_material"
+    html_show_sourcelink: False
+    pygments_style: "default"
+    html_theme_options:
+      base_url: "https://gutsche-lab.github.io/peepingtom"  # Specify a base_url used to generate sitemap.xml links
+      repo_url: "https://github.com/gutsche-lab/peepingtom"
+      repo_name: "gutsche-lab/peepingtom"
+      html_minify: True
+      css_minify: True
+      repo_type: "github"
+      globaltoc_depth: 2
+      color_primary: deep-purple
+      color_accent: "orange"
+      theme_color: "4093d6"
+      master_doc: False
+    html_sidebars:
+      "**":
+        - globaltoc.html
+        - logo-text.html
+        - localtoc.html
+        - searchbox.html
diff --git a/docs/_toc.yml b/docs/_toc.yml
new file mode 100644
index 00000000..b66ce39a
--- /dev/null
+++ b/docs/_toc.yml
@@ -0,0 +1,6 @@
+# Table of content
+# Learn more at https://jupyterbook.org/customize/toc.html
+- file: index
+
+- file: getting_started
+- file: tips_and_tricks
diff --git a/docs/getting_started.md b/docs/getting_started.md
new file mode 100644
index 00000000..f91a2c42
--- /dev/null
+++ b/docs/getting_started.md
@@ -0,0 +1,32 @@
+# Getting started
+
+## Loading data
+
+From the command line:
+```bash
+peep /path/to.star /path/to/mrc/files/
+```
+
+Alternatively, use PeepingTom from within python directly (it's designed with ipython in mind):
+```python
+import peepingtom as pt
+
+peeper = pt.peep('path_to.mrc')
+peeper.show()
+```
+
+## Navigating your data
+In the napari GUI, you'll find the PeepingTom widget at the bottom left. Select which volume to visualize with the dropdown menu.
+
+All napari functionality works as normal, and `peeper` will keep track of which layers come from where. Feel free to add any custom layer: peepingtom won't interfere with them (if it does, please report it in an issue!).
+
+```{note}
+Unless you manually generated plots and assigned them to volumes, the `Show / Hide Plots` button will show an empty widget. This is a feature ready for future versions!
+```
+
+To get a closer look at all the `DataBlocks` contained in your `Peeper`, try some of the following:
+```python
+peeper
+peeper.volumes
+peeper.particles
+```
diff --git a/docs/images/favicon.ico b/docs/images/favicon.ico
new file mode 100644
index 00000000..5ea6f758
Binary files /dev/null and b/docs/images/favicon.ico differ
diff --git a/docs/images/logo.png b/docs/images/logo.png
new file mode 100644
index 00000000..d3447c76
Binary files /dev/null and b/docs/images/logo.png differ
diff --git a/docs/index.md b/docs/index.md
new file mode 100644
index 00000000..2cb706b3
--- /dev/null
+++ b/docs/index.md
@@ -0,0 +1,4 @@
+```{include} ../README.md
+:relative-docs: docs/
+:relative-images:
+```
diff --git a/docs/requirements.txt b/docs/requirements.txt
new file mode 100644
index 00000000..65b1f157
--- /dev/null
+++ b/docs/requirements.txt
@@ -0,0 +1,2 @@
+jupyter-book
+sphinx_material
diff --git a/docs/tips_and_tricks.md b/docs/tips_and_tricks.md
new file mode 100644
index 00000000..3c56562e
--- /dev/null
+++ b/docs/tips_and_tricks.md
@@ -0,0 +1,39 @@
+# Tips and Tricks
+
+## File names and volumes
+To load your data into a `Peeper`, peepingtom needs to know how to divide it in "volumes". By default, peepingtom will try to split everything based on file names (or things like the `rlnMicrographName` filed in `.star` files) and using some [regex pattern](https://en.wikipedia.org/wiki/Regular_expression) to decide which data belongs to the same 3D volumes.
+
+By default, peepingtom recognizes files containing `TS_\d+`, and will fall back to `\d+`. For example, the following will be part of the `TS_001` volume:
+```
+CoolSample2_TS_001_something.mrc
+Particles_TS_001.star
+```
+
+These, instead, will be put simply in `02`:
+```
+image_02.em
+particles_02.tbl
+```
+
+Anything else ends up in its own `None_X` volume. If your data follows a different naming scheme, you can provide a [custom regex pattern](https://regex101.com/):
+```python
+pt.peep(YOUR_DATA, name_regex='\d_\w+')
+```
+
+## DispatchList
+To navigate nested attributes in `Peeper`, you can use the convenient `DispatchList`. Some methods of `Peeper` return this type of list, which is printed as `*[elements]*`. Try:
+```python
+peeper.datablocks.name
+# *[name1, name2, name3, ...]*
+```
+
+It works for method calls and setting values...
+```python
+peeper.particles.pixel_size = 10
+peeper.particles.
+```
+
+... and you can even dispatch getitem calls with `.disp`, similarly to pandas' `.loc`:
+```python
+peeper.datablocks.disp[:3]  # returns a view of the first 3 elements (if possible) of each datablock
+```
diff --git a/peepingtom/__main__.py b/peepingtom/__main__.py
index 834965ab..8b8c7fe5 100644
--- a/peepingtom/__main__.py
+++ b/peepingtom/__main__.py
@@ -1,74 +1,78 @@
 import sys
-import argparse
+
 from IPython.terminal.embed import InteractiveShellEmbed
+import click
 
 import peepingtom as pt
 
 
-def parse(args=None):
-    parser = argparse.ArgumentParser(formatter_class=argparse.RawDescriptionHelpFormatter,
-                                     description='''
-PeepingTom command line interface.
-
-Opens files in napari and lands in an interactive ipython shell
-with peepingtom imported as `pt` and the initialised Peeper available as `p`.
-''',
-                                     epilog=r'''
-mode choices:
-  - lone: each datablock in a separate volume
-  - zip_by_type: one of each datablock type per volume
-  - bunch: all datablocks in a single volume
-
-EXAMPLES
-
-Open a .star file as particles:
-  peep particles.star
-
-Open particles and images from a directory:
-  peep /dir/with/star_and_mrc_files/
-
-Match files such as MyProtein_10.star and MyProtein_001.mrc,
-and name the respective DataBlocks Protein_10 and Protein_001:
-  peep /path/to/dir/MyProtein* -n 'Protein_\d+'
-''')
-    parser.add_argument('paths', nargs='+',
-                        help='any number of file or directories')
-    parser.add_argument('-m', '--mode', choices=('lone', 'zip_by_type', 'bunch'),
-                        help='how to arrange DataBlock into volumes [default: guess from input]')
-    parser.add_argument('-n', '--name-regex', metavar='regex',
-                        help=r'a regex used to infer DataBlock names from paths [fallback: \d+]')
-    parser.add_argument('-r', '--recursive', action='store_true',
-                        help='read directories recursively')
-    parser.add_argument('-d', '--dry-run', action='store_true',
-                        help='only show the list of files that would be read')
-    parser.add_argument('--strict', action='store_true',
-                        help='immediately fail if a matched filename cannot be read')
-    parser.add_argument('--name', help='the name of the generated Peeper')
-    parser.add_argument('-V', '--version', action='version', version=f'%(prog)s - PeepingTom {pt.__version__}')
-
-    return parser.parse_args(args)
-
-
-def _read_embed(**kwargs):
-    sh = InteractiveShellEmbed()
-    sh.enable_gui('qt')
-
-    paths = kwargs.pop('paths')
-    p = pt.read(*paths, **kwargs)  # noqa: F841
-    sh.push('p')
-    sh.run_cell('p.show()')
-
-    sh()
-
-
-def cli(args=None):
-    args = parse(args)
-    if args.dry_run:
-        files = pt.io_.find_files(args.paths, args.filters, args.recursive, args.max)
+@click.command(context_settings=dict(help_option_names=['-h', '--help']))
+@click.argument('paths', nargs=-1)
+@click.option('-m', '--mode', type=click.Choice(['lone', 'zip_by_type', 'bunch']),
+              help='how to arrange DataBlock into volumes [default: guess from input]')
+@click.option('-n', '--name-regex', metavar='regex',
+              help=r'a regex used to infer DataBlock names from paths [fallback: \d+]')
+@click.option('-p', '--pixel-size',
+              help='manually set the pixel size (overrides the one read from file)')
+@click.option('--name', help='the name of the generated Peeper')
+@click.option('-d', '--dry-run', is_flag=True,
+              help='only show the list of files that would be read')
+@click.option('--strict', is_flag=True,
+              help='immediately fail if a matched path cannot be read')
+@click.option('--mmap', is_flag=True, default=False,
+              help='open file in memory map mode (if possible)')
+@click.option('--lazy', is_flag=True, default=True,
+              help='read data lazily (if possible)')
+def cli(paths, mode, name_regex, pixel_size, dry_run, strict, name, mmap, lazy):
+    """
+    PeepingTom command line interface.
+
+    Opens files in napari and lands in an interactive ipython shell
+    with peepingtom imported as `pt` and the initialised Peeper available as `p`.
+
+    PATHS: any number of files or globs [default='./*']
+
+    \b
+    MODE choices:
+      - lone: each datablock in a separate volume
+      - zip_by_type: one of each datablock type per volume
+      - bunch: all datablocks in a single volume
+
+    \b
+    EXAMPLES:
+    Open a .star file as particles:
+        peep particles.star
+    Open particles and images from a directory:
+        peep /dir/with/star_and_mrc_files/
+    Match files such as MyProtein_10.star and MyProtein_001.mrc,
+    and name the respective DataBlocks Protein_10 and Protein_001:
+        peep /path/to/dir/MyProtein* -n 'Protein_\d+'
+    """  # noqa: W605
+    if dry_run:
+        files = pt.io_.find_files(paths)
         print('Files found:')
         print(*(str(file) for file in files), sep='\n')
         sys.exit()
 
-    kwargs = vars(args)
-    kwargs.pop('dry_run')
-    _read_embed(**kwargs)
+    peeper = pt.read(*paths,  # noqa: F841
+                     name=name,
+                     mode=mode,
+                     name_regex=name_regex,
+                     pixel_size=pixel_size,
+                     strict=strict,
+                     mmap=mmap,
+                     lazy=lazy)
+
+    # set up ipython shell nicely
+    banner = '''=== PeepingTom ===
+initialised variables:
+    - peeper
+    - viewer
+    '''
+    sh = InteractiveShellEmbed(banner2=banner)
+    sh.enable_gui('qt')
+    sh.push('peeper')
+    sh.run_cell('peeper.show()', silent=True)
+    viewer = peeper.napari_viewer  # noqa: F841
+    sh.push('viewer')
+    sh()
diff --git a/peepingtom/datablocks/abstractblocks/simpleblock.py b/peepingtom/datablocks/abstractblocks/simpleblock.py
index 9c4eb6a4..c9db8ede 100644
--- a/peepingtom/datablocks/abstractblocks/simpleblock.py
+++ b/peepingtom/datablocks/abstractblocks/simpleblock.py
@@ -82,4 +82,7 @@ def __reversed__(self):
         yield from reversed(self.data)
 
     def __repr__(self):
-        return f'{self.__short_repr__()}\n{self.data}'
+        data_repr = ''
+        if self._loaded:
+            data_repr = f'\n{self.data}'
+        return f'{self.__short_repr__()}{data_repr}'
diff --git a/peepingtom/datablocks/abstractblocks/spatialblock.py b/peepingtom/datablocks/abstractblocks/spatialblock.py
index 9c23b0a4..c5745dcc 100644
--- a/peepingtom/datablocks/abstractblocks/spatialblock.py
+++ b/peepingtom/datablocks/abstractblocks/spatialblock.py
@@ -15,13 +15,18 @@ def __init__(self, *, pixel_size=1, dims_order='xyz', ndim=3, **kwargs):
 
     @property
     def pixel_size(self):
-        # cannot put in setter, otherwise views and children will overwrite parent time
+        # cannot put in setter, otherwise views and children will overwrite parent
         value = self.parent._pixel_size
-        if value is None or np.all(value == 0):
+        if value is None or not np.any(value):
             value = np.ones(self.ndim)
         else:
             value = np.broadcast_to(value, self.ndim)
-        return value
+        return value.astype(float)
+
+    @pixel_size.setter
+    def pixel_size(self, value):
+        self._pixel_size = value
+        self.update()
 
     @property
     def dims_order(self):
diff --git a/peepingtom/datablocks/multiblocks/orientedpointblock.py b/peepingtom/datablocks/multiblocks/orientedpointblock.py
index 9a167da0..729e4bff 100644
--- a/peepingtom/datablocks/multiblocks/orientedpointblock.py
+++ b/peepingtom/datablocks/multiblocks/orientedpointblock.py
@@ -1,3 +1,5 @@
+import xarray as xr
+
 from ..abstractblocks import SpatialBlock, MultiBlock
 from ..simpleblocks import PointBlock, OrientationBlock
 
diff --git a/peepingtom/datablocks/simpleblocks/orientationblock.py b/peepingtom/datablocks/simpleblocks/orientationblock.py
index 16c075b7..8bd4c01e 100644
--- a/peepingtom/datablocks/simpleblocks/orientationblock.py
+++ b/peepingtom/datablocks/simpleblocks/orientationblock.py
@@ -57,9 +57,17 @@ def _unit_vector(self, axis: str):
         return unit_vector
 
     def oriented_vectors(self, axis):
-        return xr.DataArray(np.dot(self.data, self._unit_vector(axis)),
-                            dims=self.data.dims[:-1],
-                            coords=[self.data.n, self.data.spatial])
+        vectors = xr.DataArray(np.dot(self.data, self._unit_vector(axis)),
+                               dims=self.data.dims[:-1],
+                               coords=[self.data.n, self.data.spatial])
+        return vectors
+
+    def zyx_vectors(self):
+        axes = [d for d in 'zyx' if d in self.dims]
+        all_vectors = {}
+        for ax in axes:
+            all_vectors[ax] = (self.oriented_vectors(ax).sel(spatial=list(axes)))
+        return all_vectors
 
     def __shape_repr__(self):
         return f'({self.n}, {self.ndim})'
diff --git a/peepingtom/datablocks/simpleblocks/pointblock.py b/peepingtom/datablocks/simpleblocks/pointblock.py
index 5b6c57ce..39efa4b5 100644
--- a/peepingtom/datablocks/simpleblocks/pointblock.py
+++ b/peepingtom/datablocks/simpleblocks/pointblock.py
@@ -39,12 +39,16 @@ def _data_setter(self, data):
     def n(self):
         return len(self.data)
 
-    def _get_named_dimensions(self, dim):
+    def _get_named_dimensions(self, dim, remainder=False):
         """
         Get data for a named dimension or multiple named dimensions of the object
         """
-        dim = list(dim)
-        return self.data.sel(spatial=dim)
+        dims = [d for d in dim if d in self.data.spatial]
+        if remainder:
+            dims.extend(d.item() for d in self.spatial if d.item() not in dims)
+        if not dims:
+            raise ValueError(f'no such spatial dimension "{dim}"')
+        return self.data.sel(spatial=dims)
 
     @property
     def x(self):
@@ -66,19 +70,6 @@ def xyz(self):
     def zyx(self):
         return self._get_named_dimensions('zyx')
 
-    def as_zyx(self):
-        """
-        return the data with the order of the spatial axes switched to 'zyx' style rather than 'xyz'
-
-        Returns
-        -------
-        correct view into data no matter the dimensionality
-        """
-        spatial = sorted([d for d in self.data.spatial.values if d in 'xyz'], reverse=True)
-        rest = [d for d in self.data.spatial.values if d not in spatial]
-        new_order = list(rest + spatial)
-        return self.data.sel(spatial=new_order)
-
     @property
     def center_of_mass(self):
         return np.mean(self.data, axis=0)
diff --git a/peepingtom/depictors/napari/linedepictor.py b/peepingtom/depictors/napari/linedepictor.py
index b8198e0c..1bac2507 100644
--- a/peepingtom/depictors/napari/linedepictor.py
+++ b/peepingtom/depictors/napari/linedepictor.py
@@ -10,7 +10,7 @@ def depict(self):
                    'edge_width': 1}
 
         # get points data in napari expected order
-        points_data = self.datablock.as_zyx()
+        points_data = self.datablock.zyx
 
         # get smooth backbone data in napari expected order
         backbone_data = self.datablock.smooth_backbone[:, ::-1]
diff --git a/peepingtom/depictors/napari/particledepictor.py b/peepingtom/depictors/napari/particledepictor.py
index ac917464..d608aa93 100644
--- a/peepingtom/depictors/napari/particledepictor.py
+++ b/peepingtom/depictors/napari/particledepictor.py
@@ -1,3 +1,4 @@
+import numpy as np
 import xarray as xr
 
 from .naparidepictor import NapariDepictor
@@ -5,52 +6,73 @@
 
 
 class ParticleDepictor(NapariDepictor):
-    def depict(self, rescale=True):
-        pkwargs = {'size': 3}
-        vkwargs = {'length': 10}
+    def __init__(self, datablock):
+        super().__init__(datablock)
+        self.point_size = 2
+        self.point_color = 'cornflowerblue'
+        self.point_edge_color = 'black'
+        self.vector_lengths = dict(z=10, y=5, x=5)
+        self.vector_widths = dict(z=0.7, y=0.5, x=0.5)
+        self.vector_colors = dict(
+            z='darkblue',
+            y='purple',
+            x='green',
+        )
+        self.rescale = 1
 
-        pos, ori = self.get_positions_and_orientations(rescale=rescale)
+    def depict(self, rescale=True):
+        if rescale:
+            self.set_rescale()
 
+        pos = self.get_positions()
         self._make_points_layer(pos,
                                 name=f'{self.name} - particle positions',
                                 scale=self.datablock.pixel_size,
-                                properties=self.get_properties(),
-                                **pkwargs)
+                                properties=self.datablock.properties.data,
+                                face_color=self.point_color,
+                                size=self.point_size,
+                                edge_color=self.point_edge_color,
+                                )
+
+        for ax, vectors in self.get_vectors().items():
+            self._make_vectors_layer(vectors.values,  # need to use values cause napari complains
+                                     name=f'{self.name} - particle orientations ({ax})',
+                                     scale=self.datablock.pixel_size,
+                                     edge_color=self.vector_colors[ax],
+                                     length=self.vector_lengths[ax],
+                                     edge_width=self.vector_widths[ax],
+                                     )
 
-        self._make_vectors_layer(ori.values,  # need to use values cause napari complains
-                                 name=f'{self.name} - particle orientations',
-                                 scale=self.datablock.pixel_size,
-                                 **vkwargs)
+    def get_positions(self):
+        return self.datablock.positions.zyx * self.rescale
 
-    def get_properties(self):
-        return self.datablock.properties.data
+    def get_vectors(self):
+        pos = self.get_positions()
+        all_vectors = self.datablock.orientations.zyx_vectors()
+        stacked = {}
+        for ax, vectors in all_vectors.items():
+            stacked[ax] = xr.concat([pos, vectors], 'v').transpose('n', 'v', 'spatial')
+        return stacked
 
-    def get_positions_and_orientations(self, rescale=True):
-        positions = self.datablock.positions.as_zyx()
-        # rescale if needed
-        if 0 <= positions.min() <= positions.max() <= 1 and rescale:
+    def set_rescale(self):
+        pos = self.datablock.positions.data
+        if 0 <= pos.min().item() <= pos.max().item() <= 1:
             peeper = self.datablock.peeper
             if peeper:
                 same_volume = peeper.volumes[self.datablock.volume]
+                from ...datablocks import ImageBlock
                 for db in same_volume:
-                    from ...datablocks import ImageBlock
                     if isinstance(db, ImageBlock):
-                        positions *= db.shape
+                        self.rescale = db.shape
                         break
 
-        v_axis = self.datablock.positions.data.spatial[-1]  # y or z
-        v_rotated = self.datablock.orientations.oriented_vectors(v_axis).loc[:, list('zyx')]
-
-        stacked = xr.concat([positions, v_rotated], dim='vector').transpose('n', 'vector', 'spatial')
-        return positions, stacked
-
     @property
     def points(self):
         return self.layers[0]
 
     @property
     def vectors(self):
-        return self.layers[1]
+        return self.layers[1:]
 
     def color_by_categorical_property(self, property, colors=None):
         if self.layers:
@@ -59,7 +81,12 @@ def color_by_categorical_property(self, property, colors=None):
 
     def update(self):
         if self.layers:
-            pos, ori = self.get_positions_and_orientations()
+            pos = self.get_positions()
             self.points.data = pos.values  # workaround for xarray
-            self.vectors.data = ori.values  # workaround for xarray
-            self.points.properties = self.get_properties()
+            self.points.scale = self.datablock.pixel_size
+            self.points.properties = self.datablock.properties.data
+
+            vectors = self.get_vectors().values()
+            for layer, vector in zip(self.vectors, vectors):
+                layer.data = vector.values
+                layer.scale = self.datablock.pixel_size
diff --git a/peepingtom/depictors/napari/pointdepictor.py b/peepingtom/depictors/napari/pointdepictor.py
index 3d0335c4..fe47cac3 100644
--- a/peepingtom/depictors/napari/pointdepictor.py
+++ b/peepingtom/depictors/napari/pointdepictor.py
@@ -5,7 +5,7 @@ class PointDepictor(NapariDepictor):
     def depict(self):
         pkwargs = {'size': 3}
 
-        self._make_points_layer(self.datablock.as_zyx(),
+        self._make_points_layer(self.datablock.zyx,
                                 name=f'{self.name}',
                                 scale=self.datablock.pixel_size,
                                 **pkwargs)
diff --git a/peepingtom/gui/viewer.py b/peepingtom/gui/viewer.py
index 01661530..ddcb71a0 100644
--- a/peepingtom/gui/viewer.py
+++ b/peepingtom/gui/viewer.py
@@ -1,8 +1,20 @@
+from contextlib import contextmanager
+
 import napari
 from pyqtgraph import GraphicsLayoutWidget
 from qtpy.QtWidgets import QVBoxLayout, QWidget, QComboBox, QPushButton
 
 
+@contextmanager
+def block_signals(widget):
+    """
+    temporarily disable signals
+    """
+    widget.blockSignals(True)
+    yield
+    widget.blockSignals(False)
+
+
 class Viewer:
     """
     abstraction layer on viewer that dispatches visualisation to specific components
@@ -72,12 +84,27 @@ def _init_pt_widget(self):
         layout.addWidget(self.plots_toggler)
 
         self._pt_napari_widget = self.napari_viewer.window.add_dock_widget(self.pt_widget,
-                                                                           name='PeepingTom - Viewer',
+                                                                           name='PeepingTom',
                                                                            area='left')
         # use napari hide and show methods
         self.pt_widget.show = self._pt_napari_widget.show
         self.pt_widget.hide = self._pt_napari_widget.hide
 
+    def update_pt_widget(self):
+        if self.pt_widget is not None:
+            current_text = self.volume_selector.currentText()
+            with block_signals(self.volume_selector):
+                self.volume_selector.clear()
+                self.volume_selector.addItems(self.peeper.volumes.keys())
+                self.volume_selector.setCurrentText(current_text)
+        self.show()
+
+    def clear_shown(self):
+        for layer in self.napari_viewer.layers.copy():
+            if layer in self.peeper.napari_layers:
+                self.napari_viewer.layers.remove(layer)
+        self.plots.clear()
+
     def show_volume(self, volume):
         if volume is None:
             return
@@ -98,10 +125,9 @@ def show_volume(self, volume):
                     plots.append(dep.plot)
         layers = sorted(layers, key=lambda l: isinstance(l, napari.layers.Image), reverse=True)
 
-        self.napari_viewer.layers.clear()
-        self.napari_viewer.layers.extend(layers)
 
-        self.plots.clear()
+        self.clear_shown()
+        self.napari_viewer.layers.extend(layers)
         for plt in plots:
             self.plots.addItem(plt)
 
diff --git a/peepingtom/io_/reading/star.py b/peepingtom/io_/reading/star.py
index a4f5d62e..76d48274 100644
--- a/peepingtom/io_/reading/star.py
+++ b/peepingtom/io_/reading/star.py
@@ -24,13 +24,19 @@
 micrograph_name_heading = 'rlnMicrographName'
 
 
-def extract_data(df, mode='RELION 3.1', name_regex=None, pixel_size=None, **kwargs):
+def extract_data(df, mode='RELION 3.1', name_regex=None, pixel_size=None, star_path='', **kwargs):
     particleblocks = []
     if coord_headings[-1] in df.columns:
         dim = 3
     else:
         dim = 2
-    for micrograph_name, df_volume in df.groupby('rlnMicrographName'):
+
+    if micrograph_name_heading in df.columns:
+        groups = df.groupby(micrograph_name_heading)
+    else:
+        groups = [(star_path, df)]
+
+    for micrograph_name, df_volume in groups:
 
         name = guess_name(micrograph_name, name_regex)
 
@@ -118,7 +124,7 @@ def read_star(star_path, **kwargs):
     failed_reader_functions = []
     for style, reader_function in reader_functions.items():
         try:
-            particle_blocks = reader_function(raw_data, **kwargs)
+            particle_blocks = reader_function(raw_data, star_path=star_path, **kwargs)
             return particle_blocks
         except ParseError:
             failed_reader_functions.append((style, reader_function))
diff --git a/peepingtom/peeper.py b/peepingtom/peeper.py
index ad1c1622..18e49c93 100644
--- a/peepingtom/peeper.py
+++ b/peepingtom/peeper.py
@@ -5,7 +5,7 @@
 import numpy as np
 
 from .datablocks import DataBlock, ParticleBlock, ImageBlock
-from .utils import DispatchList, inherit_signature, listify
+from .utils import DispatchList, listify
 from .gui import Viewer
 
 
@@ -17,10 +17,10 @@ def __init__(self, datablocks=(), name=None, view_of=None):
         self._view_of = view_of
         if name is None:
             name = token_hex(8)
-        self._name = name
+        self._viewer = None
         self._data = []
+        self._name = name
         self._extend(datablocks)
-        self._viewer = None
 
     # DATA
     @property
@@ -182,6 +182,8 @@ def _extend(self, items):
         self._hook_onto_datablocks(items)
         self._data.extend(items)
         self._data.sort(key=lambda x: x.name)
+        if self._viewer is not None:
+            self.viewer.update_pt_widget()
 
     def extend(self, items):
         if self.is_view():
diff --git a/requirements.txt b/requirements.txt
index 4022ff69..525e3e70 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,11 +1,1 @@
-napari[all]>=0.4.8
-numpy
-xarray
-pandas
-scipy
-pyqtgraph
-dynamotable>=0.2.4
-eulerangles>=1.0.1
-mrcfile>=1.3.0
-starfile>=0.4.4
-emfile>=0.2
+-e ".[dev]"
diff --git a/setup.cfg b/setup.cfg
index 91941d25..416cd8f6 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -41,6 +41,7 @@ install_requires =
     pandas
     scipy
     pyqtgraph
+    click
     dynamotable~=0.2.4
     eulerangles~=1.0.1
     mrcfile~=1.3.0
@@ -48,6 +49,12 @@ install_requires =
     emfile~=0.2
 
 
+[options.extras_require]
+dev =
+    pytest
+    pytest-qt
+
+
 [options.entry_points]
 console_scripts =
     peep = peepingtom.__main__:cli


Test patch:
diff --git a/peepingtom/_tests/test_cli.py b/peepingtom/_tests/test_cli.py
index 59a592de..49d96f96 100644
--- a/peepingtom/_tests/test_cli.py
+++ b/peepingtom/_tests/test_cli.py
@@ -1,11 +1,9 @@
-from peepingtom.__main__ import parse
+from click.testing import CliRunner
 
+from peepingtom.__main__ import cli
 
-def test_cli_parse():
-    args = parse('peep test.mrc test.star -m bunch -n reg'.split())
-    args.paths = ['test.mrc', 'test.star']
-    assert args.mode == 'bunch'
-    assert args.name_regex == 'reg'
-    assert args.recursive is False
-    assert args.dry_run is False
-    assert args.strict is False
+runner = CliRunner()
+
+
+def test_cli():
+    runner.invoke(cli, 'test.mrc test.star -m bunch -n reg'.split())
diff --git a/peepingtom/depictors/napari/_tests/test_particles.py b/peepingtom/depictors/napari/_tests/test_particles.py
index 9511e2e0..a76afe17 100644
--- a/peepingtom/depictors/napari/_tests/test_particles.py
+++ b/peepingtom/depictors/napari/_tests/test_particles.py
@@ -13,8 +13,8 @@ def test_particle_depictor():
     assert particle_depictor.datablock is particleblock
     assert len(particle_depictor.layers) == 0
     particle_depictor.depict()
-    assert len(particle_depictor.layers) == 2
+    assert len(particle_depictor.layers) == 4
     assert isinstance(particle_depictor.points, Points)
-    assert isinstance(particle_depictor.vectors, Vectors)
+    assert all(isinstance(vec, Vectors) for vec in particle_depictor.vectors)
     particle_depictor.update()
     particle_depictor.color_by_categorical_property('a')