Repository: brisvag/blik (language: python)

Issue:
Update packaging and CI
Quite a few changes to packaging and CI. All of this will be pulled out of #125.



Discussion / hints:

Fix patch:
diff --git a/README.md b/README.md
index 423f00f4..9c950559 100644
--- a/README.md
+++ b/README.md
@@ -8,36 +8,27 @@
 
 *it means glance in Dutch*
 
-**`blik`** 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.
+![blik showcase](https://user-images.githubusercontent.com/23482191/161224963-ad746a06-c2e5-46fe-a13b-f356bc4ad72b.png)
 
-**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!
+**`blik`** is a 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 development phase. Expect frequent bugs and crashes. Please, report them on the issue tracker and ask if anything is unclear!
 
 ## Installation
 
+You can either install `blik` through the [napari plugin system](https://napari.org/plugins/index.html), through pip, or get both napari and blik directly with:
+
 ```bash
-pip install blik
+pip install blik[all]
 ```
 
 ## Basic Usage
 
 From the command line:
 ```bash
-blik /path/to.star /path/to/mrc/files/
-```
-
-`blik` accepts any combination of files and directory, and will try find the right files and display them in napari. `blik` will also start an ipython shell with the following setup:
-```python
-import blik
-dataset = DataSet([your_data])
-viewer = dataset.napari_viewer
+napari /path/to.star /path/to/mrc/files/* [-w blik]
 ```
 
-For more information, check out the help page with `blik -h` and [head over to the docs](https://gutsche-lab.github.io/blik).
-
-## `napari` integration
-
-*`blik` is just `napari`*. Particles and images are exposed as simple napari layers, which can be analysed and manipulated with simple python, and most importantly [napari plugins](https://napari-hub.org/).
-
-### Property plotting
+The optional `-w blik` addition will open the main blik widget and set up a few things (such as the scale bar) on the napari viewer.
 
-Particles loaded from a star file or similar format will contain all the additional data that comes in additional columns. To plot this data and select particles based on it, check out [napari-property-plotter](https://github.com/brisvag/napari-properties-plotter).
+*`blik` is just `napari`*. Particles and images are exposed as simple napari layers, which can be analysed and manipulated with simple python, and most importantly other [napari plugins](https://napari-hub.org/).
diff --git a/blik/__init__.py b/blik/__init__.py
index 260584f5..180ac8d6 100644
--- a/blik/__init__.py
+++ b/blik/__init__.py
@@ -2,6 +2,3 @@
     from ._version import version as __version__
 except ImportError:
     __version__ = "not-installed"
-
-from .io_ import read, write
-from .dataset import DataSet
diff --git a/blik/__main__.py b/blik/__main__.py
deleted file mode 100644
index beee136a..00000000
--- a/blik/__main__.py
+++ /dev/null
@@ -1,84 +0,0 @@
-import click
-
-
-@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 DataSet')
-@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('--lazy', is_flag=True, default=True,
-              help='read data lazily (if possible)')
-@click.option('--no-show', is_flag=True,
-              help='only create the DataSet, without showing the data in napari')
-@click.version_option()
-def cli(paths, mode, name_regex, pixel_size, dry_run, strict, name, lazy, no_show):
-    """
-    Blik command line interface.
-
-    Opens files in napari and lands in an interactive ipython shell
-    with blik imported and the initialised DataSet available as `dataset`.
-
-    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:
-        blik particles.star
-    Open particles and images from a directory:
-        blik /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:
-        blik /path/to/dir/MyProtein* -n 'Protein_\d+'
-    """  # noqa: W605
-    if not paths:
-        paths = ['./*']
-
-    if dry_run:
-        from blik.io_.reading.main import find_files
-        files = [str(file) for file in find_files(paths)]
-        if files:
-            click.echo('Files found:\n' + '\n'.join(files))
-        else:
-            click.echo('No files found.')
-        click.get_current_context().exit()
-
-    import blik
-    from IPython.terminal.embed import InteractiveShellEmbed
-
-    dataset = blik.read(*paths,  # noqa: F841
-                     name=name,
-                     mode=mode,
-                     name_regex=name_regex,
-                     pixel_size=pixel_size,
-                     strict=strict,
-                     lazy=lazy,
-                     )
-
-    # set up ipython shell nicely
-    banner = '''=== Blik ===
-initialised variables:
-    - dataset
-    - viewer
-    '''
-    sh = InteractiveShellEmbed(banner2=banner)
-    sh.enable_gui('qt')
-    sh.push('dataset')
-    if not no_show:
-        sh.run_cell('dataset.show()', silent=True)
-    viewer = dataset.napari_viewer  # noqa: F841
-    sh.push('viewer')
-    sh()
diff --git a/blik/datablocks/__init__.py b/blik/datablocks/__init__.py
deleted file mode 100644
index da0f35f9..00000000
--- a/blik/datablocks/__init__.py
+++ /dev/null
@@ -1,23 +0,0 @@
-from .abstractblocks import (
-    DataBlock,
-    SimpleBlock,
-    MultiBlock,
-    SpatialBlock,
-)
-
-from .simpleblocks import (
-    ImageBlock,
-    LineBlock,
-    OrientationBlock,
-    PointBlock,
-    PropertyBlock,
-)
-
-from .multiblocks import (
-    DipoleBlock,
-    MeshBlock,
-    OrientedPointBlock,
-    ParticleBlock,
-    SphereBlock,
-    TransformBlock,
-)
diff --git a/blik/datablocks/abstractblocks/__init__.py b/blik/datablocks/abstractblocks/__init__.py
deleted file mode 100644
index 1a9a7328..00000000
--- a/blik/datablocks/abstractblocks/__init__.py
+++ /dev/null
@@ -1,5 +0,0 @@
-from .datablock import DataBlock
-from .simpleblock import SimpleBlock
-from .multiblock import MultiBlock
-
-from .spatialblock import SpatialBlock
diff --git a/blik/datablocks/abstractblocks/datablock.py b/blik/datablocks/abstractblocks/datablock.py
deleted file mode 100644
index b92af1c0..00000000
--- a/blik/datablocks/abstractblocks/datablock.py
+++ /dev/null
@@ -1,150 +0,0 @@
-from abc import ABC
-from functools import total_ordering
-from secrets import token_hex
-
-from .metablock import MetaBlock
-from ...utils import listify
-
-
-@total_ordering
-class DataBlock(ABC, metaclass=MetaBlock):
-    """
-    Base class for all datablocks.
-    """
-    _depiction_modes = {}
-
-    def __init__(self, *, name=None, volume=None, datasets=set(), multiblock=None, view_of=None, file_path=''):
-        self._multiblock = multiblock
-        self._view_of = view_of
-        if name is None:
-            name = token_hex(8)
-        # set even in case of view/multiblock, as fallback
-        self._name = name
-        self._datasets = datasets
-        self._volume = volume
-        self._file_path = file_path
-        self._depictors = []
-
-    @property
-    def view_of(self):
-        return self._view_of or self
-
-    @property
-    def multiblock(self):
-        return self._multiblock or self
-
-    @property
-    def parent(self):
-        """
-        points to the highest authority for this datablock's attributes
-        """
-        return self.view_of.multiblock
-
-    @property
-    def datasets(self):
-        return self.parent._datasets
-
-    @property
-    def name(self):
-        # not settable on purpose, should be immutable
-        return self.parent._name
-
-    @property
-    def volume(self):
-        return self.parent._volume
-
-    @volume.setter
-    def volume(self, name):
-        self.parent._volume = name
-
-    @property
-    def file_path(self):
-        return self.parent._file_path
-
-    @file_path.setter
-    def file_path(self, path):
-        self.parent._file_path = path
-
-    @property
-    def depictors(self):
-        return self.parent._depictors
-
-    @depictors.setter
-    def depictors(self, depictors):
-        self.parent._depictors = depictors
-
-    def add_to_same_volume(self, datablocks):
-        datablocks = listify(datablocks)
-        for db in datablocks:
-            db.volume = self.volume
-        for dataset in self.datasets:
-            dataset.extend(datablocks)
-
-    def __view__(self, **kwargs):
-        return type(self)(view_of=self.view_of, **kwargs)
-
-    def is_view(self):
-        return self.view_of is not self
-
-    def copy(self, new_name=None):
-        from copy import deepcopy
-        cp = deepcopy(self)
-        if new_name:
-            cp.name = new_name
-        return cp
-
-    def init_depictor(self, mode='default', new_depictor=False, **kwargs):
-        depictor_type = self._depiction_modes.get(mode)
-        if depictor_type is None:
-            raise ValueError(f'mode must be one of {tuple(self._depiction_modes.keys())}, not "{mode}"')
-        if not new_depictor:
-            for depictor in self.depictors:
-                if isinstance(depictor, depictor_type):
-                    depictor.update()
-                    return
-        self.depictors.append(depictor_type(self, **kwargs))
-
-    def update(self):
-        for depictor in self.depictors:
-            depictor.update()
-
-    def __name_repr__(self):
-        return f'<{self.name}>'
-
-    def __view_repr__(self):
-        view = ''
-        if self.is_view():
-            view = '-View'
-        return view
-
-    def __shape_repr__(self):
-        return ''
-
-    def __short_repr__(self):
-        # needed so this works for lazy multiblocs and simpleblocks
-        return (f'{type(self).__name__}{self.__view_repr__()}'
-                f'{self.__name_repr__()}{self.__shape_repr__()}')
-
-    def __repr__(self):
-        return self.__short_repr__()
-
-    def __lt__(self, other):
-        if isinstance(other, type(self)):
-            return self.name < other.name
-        else:
-            return NotImplemented
-
-    def __gt__(self, other):
-        if isinstance(other, type(self)):
-            return self.name > other.name
-        else:
-            return NotImplemented
-
-    def __eq__(self, other):
-        if isinstance(other, type(self)):
-            return self.name == other.name and self.file_path == other.file_path
-        else:
-            return NotImplemented
-
-    def __hash__(self):
-        return hash((type(self), self.name, self.file_path))
diff --git a/blik/datablocks/abstractblocks/metablock.py b/blik/datablocks/abstractblocks/metablock.py
deleted file mode 100644
index 746c23b8..00000000
--- a/blik/datablocks/abstractblocks/metablock.py
+++ /dev/null
@@ -1,48 +0,0 @@
-from abc import ABCMeta
-from inspect import signature, Parameter
-
-
-class MetaBlock(ABCMeta):
-    """
-    Metaclass for datablocks. Provides programmatic init signature construction
-    for all subclasses of DataBlock
-    """
-    def __new__(cls, name, bases, dct):
-        new_cls = super().__new__(cls, name, bases, dct)
-
-        super_params = []
-        # bubble up all parameters to the top class
-        for base in bases:
-            base_sig = signature(base)
-            params = list(base_sig.parameters.values())
-            for param in params:
-                if param.kind != Parameter.VAR_KEYWORD:
-                    super_params.append(param)
-
-        # update the signature with unique and sorted parameters
-        updated_cls = cls.update_signature(new_cls, super_params)
-        return updated_cls
-
-    @staticmethod
-    def update_signature(cls, new_params):  # cls here is the new_cls
-        # class signature (not equal to init signature if already changed!)
-        prev_sig = signature(cls)
-        prev_params = list(prev_sig.parameters.values())
-        # init signature (if different from class signature)
-        init_sig = signature(cls.__init__)
-        init_params = list(init_sig.parameters.values())
-
-        # merge and sort all params, and set new signature
-        all_params = prev_params + init_params + new_params
-        unique_params = []
-        for param in all_params:
-            if any(param.name == param_unique.name for param_unique in unique_params):
-                continue
-            unique_params.append(param)
-        unique_params.sort(key=lambda x: x.kind)
-
-        new_sig = prev_sig.replace(parameters=(unique_params))
-        # if this fails because signature is non-writeable, uninstall pyside2.
-        # for some reason, it makes all user-defined classes signatures unwriteable
-        cls.__signature__ = new_sig
-        return cls
diff --git a/blik/datablocks/abstractblocks/metamultiblock.py b/blik/datablocks/abstractblocks/metamultiblock.py
deleted file mode 100644
index f4db19e7..00000000
--- a/blik/datablocks/abstractblocks/metamultiblock.py
+++ /dev/null
@@ -1,37 +0,0 @@
-from inspect import signature, Parameter
-
-from .metablock import MetaBlock
-
-
-class MetaMultiBlock(MetaBlock):
-    """
-    Metaclass for datablocks. Provides programmatic init signature construction
-    for all subclasses of DataBlock
-    """
-    _inherited_params = ('name', 'volume', 'dataset', 'parent', 'view_of',
-                         'pixel_size', 'dims_order')
-
-    def __new__(cls, name, bases, dct):
-        # update _block_types based on bases:
-        if '_block_types' not in dct:
-            dct['_block_types'] = {}
-        block_types = dct['_block_types']
-        for base in bases:
-            base_block_types = base.__dict__.get('_block_types', {})
-            block_types.update(base_block_types)
-
-        new_cls = super().__new__(cls, name, bases, dct)
-
-        renamed_params = []
-        for block_name, block_type in block_types.items():
-            block_sig = signature(block_type)
-            params = list(block_sig.parameters.values())
-            for param in params:
-                if param.kind != Parameter.VAR_KEYWORD \
-                        and param.name not in cls._inherited_params\
-                        and param.name != 'self':
-                    par_renamed = param.replace(name=f'{block_name}_{param.name}')
-                    renamed_params.append(par_renamed)
-
-        updated_cls = cls.update_signature(new_cls, renamed_params)
-        return updated_cls
diff --git a/blik/datablocks/abstractblocks/multiblock.py b/blik/datablocks/abstractblocks/multiblock.py
deleted file mode 100644
index 1c0eab80..00000000
--- a/blik/datablocks/abstractblocks/multiblock.py
+++ /dev/null
@@ -1,45 +0,0 @@
-from .datablock import DataBlock
-from .metamultiblock import MetaMultiBlock
-
-
-class MultiBlock(DataBlock, metaclass=MetaMultiBlock):
-    """
-    Unites multiple SimpleBlocks into a more complex data object
-    """
-    _block_types = {}
-
-    def __init__(self, **kwargs):
-        self._blocks = []
-        block_args = {k: {} for k in self._block_types.keys()}
-        other_kwargs = {}
-        for kwarg, value in kwargs.items():
-            for block_name in block_args:
-                if kwarg.startswith(f'{block_name}_'):
-                    original_name = kwarg.replace(f'{block_name}_', '')
-                    block_args[block_name][original_name] = value
-                    break
-            else:
-                other_kwargs[kwarg] = value
-
-        super().__init__(**other_kwargs)
-        for block_name, block_type in self._block_types.items():
-            block = block_type(**block_args[block_name], multiblock=self)
-            self.__setattr__(block_name, block)
-            self._blocks.append(block)
-
-    @property
-    def blocks(self):
-        return self._blocks
-
-    def depict(self, mode='default', **kwargs):
-        super().init_depictor(mode=mode, **kwargs)
-        for block in self.blocks:
-            block.depictors = self.depictors
-
-    def __getitem__(self, key):
-        subslices = {}
-        for block_name in self._block_types.keys():
-            block = self.__getattribute__(block_name)
-            sliced = block.__getitem__(key)
-            subslices[f'{block_name}_data'] = sliced
-        return self.__view__(**subslices)
diff --git a/blik/datablocks/abstractblocks/simpleblock.py b/blik/datablocks/abstractblocks/simpleblock.py
deleted file mode 100644
index 5d0c848b..00000000
--- a/blik/datablocks/abstractblocks/simpleblock.py
+++ /dev/null
@@ -1,70 +0,0 @@
-from abc import abstractmethod
-import logging
-
-from .datablock import DataBlock
-
-
-logger = logging.getLogger(__name__)
-
-
-class SimpleBlock(DataBlock):
-    """
-    Base class for all simple DataBlock objects, data types which can be visualised by Depictors
-    and are defined by a single `data` attribute
-
-    SimpleBlock objects must implement a data setter method as _data_setter which returns
-    the appropriately formatted data
-
-    Calling __getitem__ on a SimpleBlock will call __getitem__ on its data property and return a view
-    """
-    def __init__(self, *, data=None, **kwargs):
-        """
-        data: a single object (typically a numpy or dask array) containing data for the datablock
-        """
-        super().__init__(**kwargs)
-        self._data = None
-        self.data = data
-
-    @property
-    def data(self):
-        return self._data
-
-    @data.setter
-    def data(self, data):
-        if isinstance(data, type(self)):
-            self._data = data.data
-        elif data is None:
-            self._data = None
-        else:
-            self._data = self._data_setter(data)
-        self.update()
-
-    @property
-    def nbytes(self):
-        return self.data.nbytes
-
-    @abstractmethod
-    def _data_setter(self, data):
-        """
-        takes raw data and returns it properly formatted to the SimpleBlock subclass specification.
-        """
-
-    def __setitem__(self, key, value):
-        self.data[key] = value
-        self.update()
-
-    def __getitem__(self, key):
-        return self.__view__(data=self.data[key])
-
-    def __len__(self):
-        return len(self.data)
-
-    def __iter__(self):
-        yield from self.data
-
-    def __reversed__(self):
-        yield from reversed(self.data)
-
-    def __repr__(self):
-        data_repr = f'\n{self.data.__repr__()}'
-        return f'{self.__short_repr__()}{data_repr}'
diff --git a/blik/datablocks/abstractblocks/spatialblock.py b/blik/datablocks/abstractblocks/spatialblock.py
deleted file mode 100644
index c0fb67a3..00000000
--- a/blik/datablocks/abstractblocks/spatialblock.py
+++ /dev/null
@@ -1,40 +0,0 @@
-from abc import ABC, abstractmethod
-
-import numpy as np
-
-
-class SpatialBlock(ABC):
-    """
-    provides spatial-related methods and properties
-    """
-    def __init__(self, *, pixel_size=1, **kwargs):
-        super().__init__(**kwargs)
-        # this needs to be after super, because the previous ones are needed for init, but this would break
-        # due to `parent` not existing yet and having a setter that needs it
-        self.pixel_size = pixel_size
-
-    @property
-    def pixel_size(self):
-        return self.parent._pixel_size
-
-    @pixel_size.setter
-    def pixel_size(self, pixel_size):
-        # cast to float first, so np.any can work
-        if isinstance(pixel_size, np.ndarray):
-            pixel_size = pixel_size.astype(float)
-        else:
-            pixel_size = float(pixel_size)
-        if not np.any(pixel_size):
-            # sometimes values are set to 0 in files, and that's obviously wrong
-            pixel_size = np.ones(3)
-        else:
-            pixel_size = np.broadcast_to(pixel_size, 3)
-        self.parent._pixel_size = pixel_size
-        self.update()
-
-    @property
-    @abstractmethod
-    def is_3D(self):
-        """
-        return whether the datablock contains any information in the third dimension
-        """
diff --git a/blik/datablocks/multiblocks/__init__.py b/blik/datablocks/multiblocks/__init__.py
deleted file mode 100644
index 8559007b..00000000
--- a/blik/datablocks/multiblocks/__init__.py
+++ /dev/null
@@ -1,6 +0,0 @@
-from .dipoleblock import DipoleBlock
-from .meshblock import MeshBlock
-from .orientedpointblock import OrientedPointBlock
-from .particleblock import ParticleBlock
-from .sphereblock import SphereBlock
-from .transformblock import TransformBlock
diff --git a/blik/datablocks/multiblocks/dipoleblock.py b/blik/datablocks/multiblocks/dipoleblock.py
deleted file mode 100644
index ff4b3961..00000000
--- a/blik/datablocks/multiblocks/dipoleblock.py
+++ /dev/null
@@ -1,78 +0,0 @@
-import numpy as np
-
-from ..abstractblocks import SpatialBlock, MultiBlock
-from ..simpleblocks import PointBlock, OrientationBlock
-from ...utils import align_vectors
-
-
-class DipoleBlock(SpatialBlock, MultiBlock):
-    """
-    A DipoleBlock represents a set of n dipoles in an m-dimensional space
-    defined by start points and end points
-    """
-    _block_types = {'startpoints': PointBlock, 'endpoints': PointBlock}
-
-    def __init__(self, **kwargs):
-        super().__init__(**kwargs)
-        # Check endpoint shape matches that of startpoints
-        self._check_shapes()
-
-    @property
-    def n(self):
-        return self.startpoints.n
-
-    def is_3D(self):
-        return self.startpoints.is_3D()
-
-    @property
-    def orientation_vectors(self):
-        """
-        Vectors describing shifts to apply on start points to arrive at end points
-        """
-        return self.endpoints.data - self.startpoints.data
-
-    @property
-    def normalised_orientation_vectors(self):
-        """
-        self.orientation_vectors normalised to length 1
-        """
-        # Calculate length of each vector, add trailing dimension
-        norm = np.expand_dims(np.linalg.norm(self.orientation_vectors, axis=1), axis=-1)
-        # Calculate and return normalised vectors
-        return self.orientation_vectors / norm
-
-    def _check_shapes(self):
-        if self.startpoints.data.shape != self.endpoints.data.shape:
-            raise ValueError('Shapes of centers and endpoints do not match')
-        else:
-            return True
-
-    def calculate_orientation_block(self, vector: np.ndarray):
-        """
-        Parameters
-        -------
-        vector : np.ndarray
-                 A vector which you would like to align with the orientation vectors of each dipole
-
-        Returns
-        -------
-        orientation_block : OrientationBlock
-                            SimpleBlock containint rotation matrices which premultiply 'vector' to
-                            align it with the orientation vectors of each dipole in this object
-        """
-        # only implemented for 3d rotations
-        if self.startpoints.data.shape[1] != 3:
-            raise NotImplementedError
-
-        # force ndarray
-        vector = np.asarray(vector).reshape(-1)
-
-        # normalise vector
-        normalised_vector = vector / np.linalg.norm(vector)
-
-        # calculate rotation matrices
-        rotation_matrices = align_vectors(normalised_vector, self.normalised_orientation_vectors)
-        return OrientationBlock(data=rotation_matrices)
-
-    def __shape_repr__(self):
-        return f'({self.n})'
diff --git a/blik/datablocks/multiblocks/meshblock.py b/blik/datablocks/multiblocks/meshblock.py
deleted file mode 100644
index cfde423b..00000000
--- a/blik/datablocks/multiblocks/meshblock.py
+++ /dev/null
@@ -1,36 +0,0 @@
-from ..abstractblocks import SpatialBlock, MultiBlock
-from ..simpleblocks import PointBlock, PropertyBlock
-
-from ...depictors import MeshDepictor
-
-
-class MeshBlock(SpatialBlock, MultiBlock):
-    """
-    Data structure for mesh data
-    """
-    _block_types = {'vertices': PointBlock, 'faces': PropertyBlock}
-    _depiction_modes = {'default': MeshDepictor}
-
-    @property
-    def is_3D(self):
-        return self.vertices.is_3D()
-
-    @property
-    def triangles(self):
-        indexes = self.faces.data.to_numpy()
-        triangles = self.vertices.data[indexes]
-        return triangles
-
-    @property
-    def midpoints(self):
-        """
-        Calculate the midpoints of each triangle in the mesh
-        Returns
-        -------
-        midpoints : (n, m)
-                    n midpoints in m dimensions
-        """
-        return self.triangles.mean(axis=1)
-
-    def __shape_repr__(self):
-        return f'{self.vertices.n, self.faces.n}'
diff --git a/blik/datablocks/multiblocks/orientedpointblock.py b/blik/datablocks/multiblocks/orientedpointblock.py
deleted file mode 100644
index 0f13640e..00000000
--- a/blik/datablocks/multiblocks/orientedpointblock.py
+++ /dev/null
@@ -1,17 +0,0 @@
-from ..abstractblocks import SpatialBlock, MultiBlock
-from ..simpleblocks import PointBlock, OrientationBlock
-
-
-class OrientedPointBlock(SpatialBlock, MultiBlock):
-    _block_types = {'positions': PointBlock, 'orientations': OrientationBlock}
-
-    @property
-    def n(self):
-        return self.positions.n
-
-    @property
-    def is_3D(self):
-        return self.positions.is_3D
-
-    def __shape_repr__(self):
-        return f'({self.n})'
diff --git a/blik/datablocks/multiblocks/particleblock.py b/blik/datablocks/multiblocks/particleblock.py
deleted file mode 100644
index 77fc83f2..00000000
--- a/blik/datablocks/multiblocks/particleblock.py
+++ /dev/null
@@ -1,37 +0,0 @@
-import numpy as np
-
-from .orientedpointblock import OrientedPointBlock
-from ..simpleblocks import PropertyBlock
-from ...depictors import ParticleDepictor
-from ...utils import listify
-
-
-class ParticleBlock(OrientedPointBlock):
-    """
-    Represents a set of particles with coordinates, orientations and arbitrary properties
-    """
-    _block_types = {'properties': PropertyBlock}
-    _depiction_modes = {
-        'default': ParticleDepictor,
-    }
-
-    def __init__(self, *, metadata=None, **kwargs):
-        super().__init__(**kwargs)
-        self.metadata = metadata or {}
-
-    def if_properties(self, conditions, index=False):
-        """
-        return a subset of the particles that satisfy the property conditions
-        conditions: a list of property names and conditions, in string form, or a single string:
-            ['prop1 > 3', 'prop2 <= 1']
-            - elements will be joined with logical & and fed to df.query
-        index: return indexes of values, not values themselves
-        """
-        # TODO: programmatic way to do it without strings?
-        conditions = listify(conditions)
-        query = ' & '.join(conditions)
-        idx = np.array(self.properties.data.query(query).index)
-        if index:
-            return self[idx], idx
-        else:
-            return self[idx]
diff --git a/blik/datablocks/multiblocks/sphereblock.py b/blik/datablocks/multiblocks/sphereblock.py
deleted file mode 100644
index 4cdf8a87..00000000
--- a/blik/datablocks/multiblocks/sphereblock.py
+++ /dev/null
@@ -1,18 +0,0 @@
-import numpy as np
-
-from ..abstractblocks import SpatialBlock, MultiBlock
-from ..simpleblocks import PointBlock, PropertyBlock
-
-
-class SphereBlock(SpatialBlock, MultiBlock):
-    """
-    Represents a set of spheres defined by centers and a radii
-    """
-    _block_types = {'centers': PointBlock, 'radii': PropertyBlock}
-
-    @property
-    def is_3D(self):
-        return True
-
-    def set_radii_from_edge_points(self, edge_points):
-        self.radii.data = np.linalg.norm(edge_points - self.centers, axis=-1)
diff --git a/blik/datablocks/multiblocks/transformblock.py b/blik/datablocks/multiblocks/transformblock.py
deleted file mode 100644
index d2242880..00000000
--- a/blik/datablocks/multiblocks/transformblock.py
+++ /dev/null
@@ -1,58 +0,0 @@
-import numpy as np
-
-from .particleblock import ParticleBlock
-from ..simpleblocks import PointBlock, OrientationBlock
-
-
-class TransformBlock(ParticleBlock):
-    """
-    A TransformBlock contains a set of 3D transforms described relative
-    to their own fixed coordinate system, made up of...
-    * positions : (n, 3) array of vectors describing shifts
-      to apply as (dx, dy, dz) relative to the origin (0, 0, 0)
-    * orientations : (n, 3, 3) array of rotation matrices which
-      rotate (by premultiplication) the unit vectors (i, j, k)
-    """
-    def apply_on(self, particles: ParticleBlock):
-        """
-        Applies the transforms encoded in this TransformBlock on a
-        set of particles in a ParticleBlock
-
-        For m transforms on n particles this results in m x n transformed particles
-
-        Parameters
-        ----------
-        particles : ParticleBlock
-
-        Returns
-        -------
-        transformed_particles : ParticleBlock
-                                Shifted, rotated particles
-
-        """
-        # set up necessary data, be explicit to avoid confusion
-        particle_positions = particles.positions.data.reshape(-1, 3, 1)  # (n, 3, 1) array
-        particle_orientations = particles.orientations  # (n, 3, 3) array of rotm
-        shift_vectors = self.positions.data.reshape(-1, 1, 3, 1)  # (m, 1, 3, 1) array
-        transform_orientations = self.orientations.data.reshape(-1, 1, 3, 3)  # (m, 1, 3, 3) array of rotm
-
-        # Orient the shift vectors according to particle orientation
-        oriented_shifts = particle_orientations @ shift_vectors  # (m, n, 3, 1)
-
-        # Apply the transformed shifts onto the current particle positions
-        output_positions = particle_positions + oriented_shifts  # (m, n, 3, 1)
-
-        # Calculate the new orientations by composing rotations
-        #  (n, 3, 3) @ (m, 1, 3, 3) -> (m, n, 3, 3)
-        output_orientations = particle_orientations @ transform_orientations
-
-        # replace nan filled matrices with original particle orientations
-        # this happens when vectors are already aligned (causes div0)
-        output_orientations = np.where(np.isnan(output_orientations), particle_orientations,
-                                       output_orientations)
-
-        # Create and return ParticleBlock from output
-        points = PointBlock(output_positions.reshape((-1, 3)))
-        orientations = OrientationBlock(output_orientations.reshape(-1, 3, 3))
-
-        return ParticleBlock(points, orientations, {})
diff --git a/blik/datablocks/simpleblocks/__init__.py b/blik/datablocks/simpleblocks/__init__.py
deleted file mode 100644
index 5937c8aa..00000000
--- a/blik/datablocks/simpleblocks/__init__.py
+++ /dev/null
@@ -1,5 +0,0 @@
-from .imageblock import ImageBlock
-from .lineblock import LineBlock
-from .orientationblock import OrientationBlock
-from .pointblock import PointBlock
-from .propertyblock import PropertyBlock
diff --git a/blik/datablocks/simpleblocks/imageblock.py b/blik/datablocks/simpleblocks/imageblock.py
deleted file mode 100644
index 87993228..00000000
--- a/blik/datablocks/simpleblocks/imageblock.py
+++ /dev/null
@@ -1,39 +0,0 @@
-import logging
-
-import numpy as np
-import dask.array as da
-
-from ..abstractblocks import SpatialBlock, SimpleBlock
-from ...depictors import ImageDepictor
-
-
-logger = logging.getLogger(__name__)
-
-
-class ImageBlock(SpatialBlock, SimpleBlock):
-    """
-    Image block
-
-    data must have at least 2 dims; spatial dimensions are ordered zyx
-    """
-    _depiction_modes = {'default': ImageDepictor}
-
-    def _data_setter(self, data):
-        data = da.asarray(data)
-        if data.ndim < 2:
-            raise ValueError('images must have at least 2 dimensions')
-        if data.ndim == 2:
-            # prepend z dim if missing
-            data = data[np.newaxis]
-        return data
-
-    @property
-    def shape(self):
-        return self.data.shape
-
-    @property
-    def is_3D(self):
-        return self.shape[-3] > 1
-
-    def __shape_repr__(self):
-        return f'{self.shape}'
diff --git a/blik/datablocks/simpleblocks/lineblock.py b/blik/datablocks/simpleblocks/lineblock.py
deleted file mode 100644
index 3d35df99..00000000
--- a/blik/datablocks/simpleblocks/lineblock.py
+++ /dev/null
@@ -1,56 +0,0 @@
-import numpy as np
-from scipy.interpolate import splprep, splev
-
-from .pointblock import PointBlock
-from ...depictors import LineDepictor
-
-
-class LineBlock(PointBlock):
-    """
-    LineBlock objects represent lines with convenience methods
-
-    LineBlock data should be array-like objects of shape (n, d) representing n points in d dimensions
-    """
-    _depiction_modes = {'default': LineDepictor}
-
-    def __init__(self, *, spline_smoothing_parameter=0, **kwargs):
-        super().__init__(**kwargs)
-
-        # initialise attributes related to spline fitting
-        self.spline_smoothing_parameter = spline_smoothing_parameter
-        self._tck = None
-
-    @property
-    def spline_smoothing_parameter(self):
-        return self._spline_smoothing_parameter
-
-    @spline_smoothing_parameter.setter
-    def spline_smoothing_parameter(self, value):
-        self._spline_smoothing_parameter = float(value)
-
-    def fit_spline(self, dimensions='xyz', smoothing_parameter=None):
-        """
-        dimensions :  str of named dimensions ('xyz') to which a spline should be fit
-        smoothing_parameter : smoothing parameter for spline fitting
-
-        Returns tck, list of spline parameters from scipy.interpolate.splprep
-        """
-        if smoothing_parameter:
-            self.spline_smoothing_parameter = smoothing_parameter
-
-        dims_to_fit = self._get_named_dimensions(dimensions).T
-        self._tck, _ = splprep(dims_to_fit, s=self.spline_smoothing_parameter)
-
-        return self._tck
-
-    def evaluate_spline(self, n_points):
-        u = np.linspace(0, 1, n_points, endpoint=True)
-        return np.asarray(splev(u, tck=self._tck)).T
-
-    @property
-    def smooth_backbone(self):
-        return self._generate_smooth_backbone()
-
-    def _generate_smooth_backbone(self, n_points=1000):
-        self.fit_spline()
-        return self.evaluate_spline(n_points)
diff --git a/blik/datablocks/simpleblocks/orientationblock.py b/blik/datablocks/simpleblocks/orientationblock.py
deleted file mode 100644
index c94acc91..00000000
--- a/blik/datablocks/simpleblocks/orientationblock.py
+++ /dev/null
@@ -1,45 +0,0 @@
-import numpy as np
-
-from ..abstractblocks import SpatialBlock, SimpleBlock
-
-
-class OrientationBlock(SpatialBlock, SimpleBlock):
-    """
-    OrientationBlock objects represent orientations in nd space
-
-    data must be (n, 3, 3) or (n, 2, 2), and will be reshaped to (n, 3, 3)
-
-    order of spatial dimensions is xyz
-    """
-    def _data_setter(self, data):
-        data = np.array(data)
-        # check for single matrix case and assert dimensionality
-        if data.ndim < 3:
-            data = data[np.newaxis]
-        if data.shape[-1] != data.shape[-2] or data.shape[-1] not in (2, 3):
-            raise ValueError(f'rotation matrices should be of shape '
-                             f'(n, 2, 2) or (n, 3, 3), '
-                             f'not {data.shape}')
-
-        # coerce to 3D
-        if data.shape[-1] == 2:
-            data = np.pad(data, ((0, 0), (0, 1), (0, 1)))
-            data[:, -1, -1] = 1
-        return data
-
-    @property
-    def n(self):
-        return len(self.data)
-
-    @property
-    def is_3D(self):
-        return np.any(self.data[:, 2, :2]) \
-            and np.any(self.data[:, :2, 2]) \
-            and np.any(self.data[:, 2, 2] != 1)
-
-    @property
-    def as_zyx(self, ):
-        return self.data[:, ::-1, ::-1]
-
-    def __shape_repr__(self):
-        return f'({self.n})'
diff --git a/blik/datablocks/simpleblocks/pointblock.py b/blik/datablocks/simpleblocks/pointblock.py
deleted file mode 100644
index fab1fcb7..00000000
--- a/blik/datablocks/simpleblocks/pointblock.py
+++ /dev/null
@@ -1,104 +0,0 @@
-import numpy as np
-
-from ..abstractblocks import SpatialBlock, SimpleBlock
-from ...depictors import PointDepictor
-
-from ...utils import dim_names_to_indexes
-
-
-class PointBlock(SpatialBlock, SimpleBlock):
-    """
-    PointBlock objects for representing points with convenience methods
-
-    PointBlock data should be array-like objects of shape (n, d) representing n points in d dimensions
-
-    Spatial dimensions are ordered xyz
-    """
-    _depiction_modes = {'default': PointDepictor}
-
-    def _data_setter(self, data):
-        # cast as array
-        data = np.asarray(data)
-
-        if data.size == 0:
-            data = data.reshape(0, 3)
-
-        if data.ndim != 2:
-            raise ValueError("point data should have shape (n, d)")
-
-        # coerce to 3 spatial dims
-        missing_dims = max(3 - data.shape[1], 0)
-        data = np.pad(data, ((0, 0), (0, missing_dims)))
-
-        return data
-
-    @property
-    def n(self):
-        return len(self.data)
-
-    @property
-    def is_3D(self):
-        return np.any(self.data[-1])
-
-    def _get_named_dimensions(self, dims):
-        """
-        Get data for a named dimension or multiple named dimensions of the object
-        """
-        arrays = []
-        for idx in dim_names_to_indexes(dims):
-            arrays.append(self.data[:, idx])
-        return np.stack(arrays, axis=-1)
-
-    @property
-    def x(self):
-        return self._get_named_dimensions('x')
-
-    @property
-    def y(self):
-        return self._get_named_dimensions('y')
-
-    @property
-    def z(self):
-        return self._get_named_dimensions('z')
-
-    @property
-    def xyz(self):
-        return self._get_named_dimensions('xyz')
-
-    @property
-    def zyx(self):
-        return self._get_named_dimensions('zyx')
-
-    @property
-    def as_zyx(self):
-        """
-        returns spatial dimns as zyx but leaves the rest untouched
-        """
-        rest = self.data[:, :-3]
-        return np.concatenate([rest, self.zyx], axis=-1)
-
-    @property
-    def center_of_mass(self):
-        return np.mean(self.data, axis=0)
-
-    def distance_to(self, point):
-        """
-        Calculate the euclidean distance between the center of mass of this object and a point
-
-        Parameters
-        ----------
-        point : array-like object
-
-        Returns : euclidean distance
-        -------
-
-        """
-        point = np.asarray(point)
-        if not point.shape == self.center_of_mass.shape:
-            raise ValueError(
-                f"shape of point '{point.shape}' does not match shape of center of mass "
-                f"'{self.center_of_mass.shape}'")
-        return np.linalg.norm(point - self.center_of_mass)
-
-    def __shape_repr__(self):
-        return f'{self.data.shape}'
diff --git a/blik/datablocks/simpleblocks/propertyblock.py b/blik/datablocks/simpleblocks/propertyblock.py
deleted file mode 100644
index 1d93629e..00000000
--- a/blik/datablocks/simpleblocks/propertyblock.py
+++ /dev/null
@@ -1,60 +0,0 @@
-import numpy as np
-import pandas as pd
-
-from ..abstractblocks import SimpleBlock
-from ...depictors import PropertyPlotDepictor
-
-
-class PropertyBlock(SimpleBlock):
-    """
-    PropertyBlock is a simple dataframe wrapper with datablock api
-    data: dataframe or dict whose values all have the same length
-    """
-    _depiction_modes = {
-        'default': PropertyPlotDepictor
-    }
-
-    def __init__(self, *, protected=None, **kwargs):
-        super().__init__(**kwargs)
-        self._protected = protected or []
-
-    def _data_setter(self, data=None):
-        try:
-            data = pd.DataFrame(data)
-        except ValueError:
-            data = pd.DataFrame(np.array(data).reshape(-1))
-        return data
-
-    @property
-    def n(self):
-        return len(self)
-
-    def items(self):
-        # numpy array instead of pandas series, for compatibility
-        for k, v in self.data.items():
-            yield k, np.array(v)
-
-    def __setitem__(self, key, value):
-        # does not work on view (we lose track of how nested we are and how we got here)
-        if self.is_view():
-            raise TypeError('cannot set data with nested getitem calls.')
-        # this allows indexing using slices and/or functionality like ParticleBlock.if_properties()
-        if isinstance(key, (list)) and all(isinstance(i, (int, bool)) for i in key) or \
-                isinstance(key, (np.ndarray, pd.Index)):
-            self.data.iloc.__setitem__(key, value)
-        else:
-            if key in self._protected:
-                raise KeyError(f'"{key}" is a protected property and cannot be changed')
-            self.data.__setitem__(key, value)
-
-    def __getitem__(self, key):
-        # this allows indexing using slices and/or functionality like ParticleBlock.if_properties()
-        if isinstance(key, (list)) and all(isinstance(i, (int, bool)) for i in key) or \
-                isinstance(key, (np.ndarray, pd.Index)):
-            data = self.data.iloc.__getitem__(key)
-        else:
-            data = self.data.__getitem__(key)
-        return self.__view__(data=data)
-
-    def __shape_repr__(self):
-        return f'({self.data.shape})'
diff --git a/blik/dataset/__init__.py b/blik/dataset/__init__.py
deleted file mode 100644
index 2a8a79e6..00000000
--- a/blik/dataset/__init__.py
+++ /dev/null
@@ -1,2 +0,0 @@
-from .dataset import DataSet
-from .operations import stack_particles_series
diff --git a/blik/dataset/dataset.py b/blik/dataset/dataset.py
deleted file mode 100644
index 01456bb0..00000000
--- a/blik/dataset/dataset.py
+++ /dev/null
@@ -1,312 +0,0 @@
-from collections import defaultdict
-from secrets import token_hex
-from math import log10, ceil
-
-import numpy as np
-
-from ..datablocks import DataBlock, ParticleBlock, ImageBlock
-from ..utils import DispatchList, listify
-from ..gui import Viewer
-
-
-class DataSet:
-    """
-    A container for a collection of DataBlocks
-    """
-    def __init__(self, datablocks=(), name=None, view_of=None):
-        self._view_of = view_of
-        if name is None:
-            name = token_hex(8)
-        self._viewer = None
-        self._data = []
-        self._name = name
-        self._extend(datablocks)
-
-    # DATA
-    @property
-    def view_of(self):
-        return self._view_of or self
-
-    @property
-    def name(self):
-        return self.view_of._name
-
-    @property
-    def datablocks(self):
-        return DispatchList(self._data)
-
-    @property
-    def volumes(self):
-        volumes = self._nested()
-        volumes.pop('BLIK_OMNI', None)
-        return volumes
-
-    @property
-    def omni(self):
-        return self._nested().get('BLIK_OMNI', DispatchList())
-
-    def is_view(self):
-        return self.view_of is not self
-
-    def copy(self, new_name=None):
-        cp = DataSet(self, name=new_name)
-        return cp
-
-    def _sanitize(self, iterable, deduplicate=True):
-        listified = listify(iterable)
-        for item in listified:
-            if not isinstance(item, DataBlock):
-                raise TypeError(f'DataSet can only hold DataBlock objects, not "{type(item).__name__}"')
-        if deduplicate:
-            deduplicated = []
-            while listified:
-                el = listified.pop()
-                if el in self or el in deduplicated:
-                    pass
-                else:
-                    deduplicated.append(el)
-            listified = deduplicated
-        return listified
-
-    def _hook_onto_datablocks(self, datablocks):
-        if not self.is_view():
-            for db in datablocks:
-                db.datasets.add(self)
-
-    def _nested(self, as_list=False):
-        nested = defaultdict(list)
-        i = 0
-        length = len(self._data) or 1  # avoid math domain error
-        pad = ceil(log10(length))
-        for el in self._data:
-            if el.volume is None:
-                nested[f'None_{i:0{pad}}'].append(el)
-                i += 1
-            else:
-                nested[el.volume].append(el)
-        if as_list:
-            return list(nested.values())
-        return dict(nested)
-
-    def _filter_types(self, block_types):
-        """
-        return a view containing only the chosen block types
-        """
-        block_types = tuple(listify(block_types))
-
-        def right_type(item):
-            return isinstance(item, block_types)
-        filtered = filter(lambda x: isinstance(x, block_types), self)
-        return DispatchList(filtered)
-
-    @property
-    def particles(self, flatten=False):
-        return self._filter_types(ParticleBlock)
-
-    @property
-    def images(self, flatten=False):
-        return self._filter_types(ImageBlock)
-
-    def __view__(self, *args, **kwargs):
-        return DataSet(*args, view_of=self.view_of, **kwargs)
-
-    def __getitems__(self, key):
-        out = []
-        if isinstance(key, int):
-            out.append(self._data[key])
-        elif isinstance(key, slice):
-            out.extend(self._data[key])
-        elif isinstance(key, str):
-            for vol, dbs in self.volumes.items():
-                if vol == key:
-                    out.extend(dbs)
-                    break
-            else:
-                for db in self:
-                    if db.name == key:
-                        out.append(db)
-                        break
-        elif isinstance(key, (list, tuple, np.ndarray)):
-            if all(isinstance(i, bool) for i in key):
-                out.extend(list(np.array(self._data)[key]))
-            else:
-                for k in key:
-                    out.extend(self.__locitem__(k))
-        if not out:
-            raise KeyError(f'{key}')
-        return out
-
-    def __getitem__(self, key):
-        items = self.__getitems__(key)
-        if len(items) == 1 and isinstance(key, (str, int)):
-            # to enforce getting a dataset, you can simply use a 1-tuple as key
-            return items[0]
-        return self.__view__(items)
-
-    def find_datablocks(self, name=None, volume=None, type=None):
-        if name is volume is type is None:
-            raise ValueError('at least one of "name", "volume" or "type" must be provided')
-        if type is None:
-            type = DataBlock
-        dbs = self._filter_types(type)
-        if name is not None:
-            dbs = [db for db in dbs if name in db.name]
-        if volume:
-            dbs = [db for db in dbs if volume in db.volume]
-        if not dbs:
-            raise ValueError(f'no datablock corresponds to {name=}, {volume=} and {type=}')
-        else:
-            return DispatchList(dbs)
-
-    def __iter__(self):
-        yield from self._data
-
-    def __contains__(self, item):
-        return item in self._data
-
-    def __len__(self):
-        return len(self._data)
-
-    def append(self, item):
-        self.extend(item)
-
-    def _extend(self, items):
-        """
-        must be called by init to extend, otherwise views fail
-        """
-        items = self._sanitize(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_blik_widget()
-
-    def extend(self, items):
-        if self.is_view():
-            raise TypeError('DataSet view is immutable')
-        self._extend(items)
-
-    def remove(self, item):
-        self._data.remove(item)
-
-    def __add__(self, other):
-        if isinstance(other, (DataSet, list)):
-            return DataSet(self._data + self._sanitize(other))
-        else:
-            return NotImplemented
-
-    def __iadd__(self, other):
-        if isinstance(other, (DataSet, list)):
-            self.extend(other)
-            return self
-        else:
-            return NotImplemented
-
-    # REPRESENTATION
-    def __shape_repr__(self):
-        return f'({len(self._nested())}, {len(self.datablocks)})'
-
-    def __name_repr__(self):
-        return f'<{self.name}>'
-
-    def __view_repr__(self):
-        view = ''
-        if self.is_view():
-            view = '-View'
-        return view
-
-    def __base_repr__(self):
-        return (f'{type(self).__name__}{self.__view_repr__()}'
-                f'{self.__name_repr__()}{self.__shape_repr__()}')
-
-    def __pretty_repr__(self, mode):
-        modes = ('base', 'flat_compact', 'flat', 'nested_compact', 'nested', 'full')
-        if mode not in modes:
-            raise ValueError(f'available modes are {", ".join(modes)}; got "{mode}"')
-        if mode == 'base':
-            return self.__base_repr__()
-        contents = []
-        for volume, dbs in self._nested().items():
-            if mode in ('flat_compact', 'flat'):
-                vol_rep = f'{volume}({len(dbs)})'
-            else:
-                vol_rep = []
-                vol_contents = [f'{db.__short_repr__()}' for db in dbs]
-                if mode in ('nested_compact', 'nested') and len(vol_contents) > 7:
-                    vol_contents = vol_contents[:3] + ['[...]'] + vol_contents[-3:]
-                vol_contents_repr = '\n        '.join(vol_contents)
-                vol_rep = f'{volume}({len(dbs)}):\n        {vol_contents_repr}'
-            contents.append(vol_rep)
-        if mode in ('flat_compact', 'nested_compact') and len(contents) > 7:
-            contents = contents[:3] + ['[...]'] + contents[-3:]
-        contents_rep = '\n    '.join(contents)
-
-        return f'{self.__base_repr__()}:\n    {contents_rep}'
-
-    def __repr__(self):
-        return self.__pretty_repr__('nested_compact')
-
-    def pprint(self, mode='full'):
-        print(self.__pretty_repr__(mode))
-
-    # VISUALISATION
-    @property
-    def viewer(self):
-        if self.view_of._viewer is None:
-            self.view_of._viewer = Viewer(self)
-        return self.view_of._viewer
-
-    def show(self, **kwargs):
-        self.viewer.show(**kwargs)
-        return self.viewer
-
-    @property
-    def shown(self):
-        return self.viewer.shown
-
-    @property
-    def depictors(self):
-        return DispatchList(dep for db in self for dep in db.depictors)
-
-    @property
-    def napari_layers(self):
-        layers = DispatchList()
-        for dep in self.depictors:
-            layers.extend(getattr(dep, 'layers', []))
-        return layers
-
-    def purge_gui(self):
-        for dep in self.depictors:
-            dep.purge()
-
-    def purge_depictors(self):
-        for db in self:
-            db.depictors = []
-
-    @property
-    def napari_viewer(self):
-        return self.viewer.napari_viewer
-
-    @property
-    def plots(self):
-        plots = DispatchList()
-        for dep in self.depictors:
-            plot = getattr(dep, 'plot', None)
-            if plot:
-                plots.append(plot)
-        return plots
-
-    # IO
-    def read(self, paths, **kwargs):
-        """
-        read paths into datablocks and append them to the datacrates
-        """
-        from .io_ import read
-        self.extend(read(paths, **kwargs))
-
-    def write(self, paths, **kwargs):
-        """
-        write datablock contents to disk
-        """
-        from .io_ import write
-        write(self, paths, **kwargs)
diff --git a/blik/dataset/operations.py b/blik/dataset/operations.py
deleted file mode 100644
index a7ace400..00000000
--- a/blik/dataset/operations.py
+++ /dev/null
@@ -1,31 +0,0 @@
-import numpy as np
-import pandas as pd
-
-from ..datablocks import ParticleBlock
-from ..dataset import DataSet
-
-
-def stack_particles_series(dataset):
-    """
-    stacks a dataset into a time series
-    assumes equal number of particles per time step
-    """
-    all_stacked = []
-    for v_name in dataset.volumes.keys():
-        particles = dataset[v_name].particles
-        rest = [pb for pb in dataset if pb not in particles]
-        positions = np.concatenate(particles.positions.data)
-        # add a dim to the left and number it
-        positions = np.pad(positions, ((0, 0), (1, 0)))
-        rng = np.repeat(np.arange(len(particles)), particles[0].n)
-        positions[:, 0] = rng
-        orientations = np.concatenate(particles.orientations.data)
-        properties = pd.concat(particles.properties.data)
-        stacked = ParticleBlock(positions_data=positions,
-                                orientations_data=orientations,
-                                properties_data=properties,
-                                volume=v_name)
-        all_stacked.append(stacked)
-        all_stacked.extend(rest)
-
-    return DataSet(all_stacked)
diff --git a/blik/depictors/__init__.py b/blik/depictors/__init__.py
deleted file mode 100644
index 37518a47..00000000
--- a/blik/depictors/__init__.py
+++ /dev/null
@@ -1,11 +0,0 @@
-from .napari import (
-    ImageDepictor,
-    LineDepictor,
-    MeshDepictor,
-    ParticleDepictor,
-    PointDepictor,
-)
-
-from .pyqtgraph import (
-    PropertyPlotDepictor,
-)
diff --git a/blik/depictors/depictor.py b/blik/depictors/depictor.py
deleted file mode 100644
index 06bbb0ec..00000000
--- a/blik/depictors/depictor.py
+++ /dev/null
@@ -1,38 +0,0 @@
-class Depictor:
-    """
-    base depictor class. Subclasses should call super().__init__ last, to properly
-    create a depiction
-    """
-    def __init__(self, datablock):
-        self.datablock = datablock
-
-    @property
-    def name(self):
-        return self.datablock.name
-
-    def depict(self, **kwargs):
-        """
-        generate or update depictions based on current parameters
-        """
-        raise NotImplementedError
-
-    def update(self):
-        """
-        update the displayed data based on the current state of the datablock
-        """
-
-    def changed(self, event):
-        """
-        fired when data is changed from the depiction side.
-        Subclasses can overload this method with the logic to update
-        the data in the datablock accordingly
-        don't forget to accept the `event` argument!
-        """
-
-    def purge(self):
-        """
-        delete all depictions, resetting to initialized state
-        """
-
-    def __repr__(self):
-        return f'{type(self).__name__}{self.datablock.__name_repr__()}'
diff --git a/blik/depictors/napari/__init__.py b/blik/depictors/napari/__init__.py
deleted file mode 100644
index cad9760d..00000000
--- a/blik/depictors/napari/__init__.py
+++ /dev/null
@@ -1,6 +0,0 @@
-from .naparidepictor import NapariDepictor
-from .imagedepictor import ImageDepictor
-from .linedepictor import LineDepictor
-from .meshdepictor import MeshDepictor
-from .particledepictor import ParticleDepictor
-from .pointdepictor import PointDepictor
diff --git a/blik/depictors/napari/imagedepictor.py b/blik/depictors/napari/imagedepictor.py
deleted file mode 100644
index fb1f1118..00000000
--- a/blik/depictors/napari/imagedepictor.py
+++ /dev/null
@@ -1,8 +0,0 @@
-from .naparidepictor import NapariDepictor
-
-
-class ImageDepictor(NapariDepictor):
-    def depict(self):
-        self._make_image_layer(self.datablock.data,
-                               name=f'{self.name} - image',
-                               scale=self.datablock.pixel_size)
diff --git a/blik/depictors/napari/linedepictor.py b/blik/depictors/napari/linedepictor.py
deleted file mode 100644
index 37762a4c..00000000
--- a/blik/depictors/napari/linedepictor.py
+++ /dev/null
@@ -1,45 +0,0 @@
-from .naparidepictor import NapariDepictor
-
-
-class LineDepictor(NapariDepictor):
-    def depict(self):
-        # default keyword arguments
-        pkwargs = {'size': 3,
-                   'face_color': 'cornflowerblue'}
-        bkwargs = {'edge_color': 'orangered',
-                   'edge_width': 1}
-
-        # get points data in napari expected order
-        points_data = self.datablock.as_zyx
-
-        # get smooth backbone data in napari expected order
-        backbone_data = self.datablock.smooth_backbone[:, ::-1]
-
-        # draw points layer in napari
-        self._make_points_layer(points_data,
-                                name=f'{self.name} - points',
-                                **pkwargs)
-
-        # draw line as path in napari
-        self._make_shapes_layer(backbone_data,
-                                shape_type='path',
-                                name=f'{self.name} - line',
-                                **bkwargs)
-
-    @property
-    def points(self):
-        return self.layers[0]
-
-    @property
-    def backbone(self):
-        return self.layers[1]
-
-    def update(self):
-        if self.layers:
-            backbone_data = self.datablock.smooth_backbone[:, ::-1]
-            self.backbone.selected_data = {0}
-            self.backbone.remove_selected()
-            self.backbone.add(backbone_data, shape_type='path')
-
-    def changed(self, event):
-        self.datablock.data = self.points.data[:, ::-1]
diff --git a/blik/depictors/napari/meshdepictor.py b/blik/depictors/napari/meshdepictor.py
deleted file mode 100644
index 81f02650..00000000
--- a/blik/depictors/napari/meshdepictor.py
+++ /dev/null
@@ -1,8 +0,0 @@
-from .naparidepictor import NapariDepictor
-
-
-class MeshDepictor(NapariDepictor):
-    def depict(self):
-        self._make_surface_layer(self.datablock.vertices.data,
-                                 self.datablock.faces.data,
-                                 name=f'{self.name} - mesh')
diff --git a/blik/depictors/napari/naparidepictor.py b/blik/depictors/napari/naparidepictor.py
deleted file mode 100644
index 8ca39038..00000000
--- a/blik/depictors/napari/naparidepictor.py
+++ /dev/null
@@ -1,51 +0,0 @@
-import numpy as np
-
-from ..depictor import Depictor
-
-
-class NapariDepictor(Depictor):
-    """
-    NapariDepictors are SimpleBlock or MultiBlock wrappers controlling depiction of their contents in napari
-    """
-    def __init__(self, datablock):
-        super().__init__(datablock)
-        self.layers = []
-
-    def _make_image_layer(self, image, name, scale=None, **kwargs):
-        from napari.layers import Image
-        layer = Image(image, name=name, scale=scale, **kwargs)
-        self._init_layer(layer)
-
-    def _make_points_layer(self, points, name, **kwargs):
-        from napari.layers import Points
-        layer = Points(points, name=name, n_dimensional=True, **kwargs)
-        self._init_layer(layer)
-
-    def _make_vectors_layer(self, vectors, name, **kwargs):
-        from napari.layers import Vectors
-        layer = Vectors(vectors, name=name, **kwargs)
-        self._init_layer(layer)
-
-    def _make_shapes_layer(self, shape, shape_type, name, **kwargs):
-        from napari.layers import Shapes
-        layer = Shapes(shape, shape_type=shape_type, name=name, **kwargs)
-        self._init_layer(layer)
-
-    def _make_surface_layer(self, vertices, faces, name, values=None, **kwargs):
-        from napari.layers import Surface
-        if values is None:
-            values = np.ones(vertices.shape[0])
-        data = (vertices, faces, values)
-        layer = Surface(data, name=name, **kwargs)
-        self._init_layer(layer)
-
-    def _init_layer(self, layer):
-        """
-        connects events on layer data to an update of the relative datablock
-        and adds it to the depictor's layer list
-        """
-        layer.events.data.connect(self.changed)
-        self.layers.append(layer)
-
-    def purge(self):
-        self.layers.clear()
diff --git a/blik/depictors/napari/particledepictor.py b/blik/depictors/napari/particledepictor.py
deleted file mode 100644
index 0696ebf9..00000000
--- a/blik/depictors/napari/particledepictor.py
+++ /dev/null
@@ -1,128 +0,0 @@
-import numpy as np
-
-from .naparidepictor import NapariDepictor
-from ...utils import distinct_colors, dim_names_to_indexes
-
-
-class ParticleDepictor(NapariDepictor):
-    def __init__(self, datablock):
-        super().__init__(datablock)
-        self.point_size = 40
-        self.point_color = 'cornflowerblue'
-        self.point_edge_color = 'black'
-        self.vector_lengths = dict(z=150, y=75, x=75)
-        self.vector_widths = dict(z=15, y=15, x=15)
-        self.vector_colors = dict(
-            z='darkblue',
-            y='purple',
-            x='green',
-        )
-        self._rescale = 1
-
-    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',
-                                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,
-                                     name=f'{self.name} - particle orientations ({ax})',
-                                     edge_color=self.vector_colors[ax],
-                                     length=self.vector_lengths[ax],
-                                     edge_width=self.vector_widths[ax],
-                                     )
-
-    def _pad_to_ndim(self, array, fill_value):
-        """
-        pad an array containing only spatial dimensions to match the total amount of dimensions
-        of the particles (including non-spatial ones)
-
-        fill_value: value used to fill the padding
-        """
-        non_spatial_dims = max(self.datablock.positions.data.shape[1] - 3, 0)
-        if array.ndim == 1:
-            # 1D array, pad to the left
-            return np.pad(array, (non_spatial_dims, 0), constant_values=fill_value)
-        elif array.ndim == 2:
-            # 2D array, pad to the left of 2nd dim
-            return np.pad(array, ((0, 0), (non_spatial_dims, 0)), constant_values=fill_value)
-        raise ValueError(f"cannot pad array of shape {array.shape}")
-
-    def get_positions(self):
-        positions = self.datablock.positions.as_zyx
-        # padding must be done with ones to be neutral in multiplication
-        padded_pixel_size = self._pad_to_ndim(self.datablock.pixel_size, 1)
-        padded_rescale = self._pad_to_ndim(self.rescale, 1)
-        return positions * padded_rescale * padded_pixel_size
-
-    def _unit_vector(self, axis):
-        idx = dim_names_to_indexes(axis, order='zyx')[0]
-        # initialise unit vector array
-        unit_vector = np.zeros(3, dtype=float)
-        unit_vector[idx] = 1
-        return unit_vector
-
-    def get_vectors(self):
-        pos = self.get_positions()
-        ori = self.datablock.orientations.as_zyx
-        all_vectors = {}
-        for ax in 'zyx':
-            vectors = ori @ self._unit_vector(ax)
-            # must pad to match positions (with eros, otherwise vectors will pierce non-spatial dims)
-            shifted_vectors = np.stack([pos, self._pad_to_ndim(vectors, 0)], axis=1)
-            all_vectors[ax] = shifted_vectors
-        return all_vectors
-
-    def set_rescale(self):
-        """
-        dig through all the datablocks sharing this volume. If any are images, use their shape
-        to rescale the normalized coords of the particles to the original ones
-        """
-        if self.datablock.volume is None:
-            # None volumes are by definition single datablocks so we can should skip them
-            return
-        pos = self.datablock.positions.data
-        if 0 <= pos.min().item() <= pos.max().item() <= 1:
-            datasets = self.datablock.datasets
-            for dataset in datasets:
-                same_volume = dataset.volumes[self.datablock.volume]
-                from ...datablocks import ImageBlock
-                for db in same_volume:
-                    if isinstance(db, ImageBlock):
-                        self._rescale = db.shape
-                        break
-
-    @property
-    def rescale(self):
-        return np.broadcast_to(self._rescale, (3,))
-
-    @property
-    def points(self):
-        return self.layers[0]
-
-    @property
-    def vectors(self):
-        return self.layers[1:]
-
-    def color_by_categorical_property(self, property, colors=None):
-        if self.layers:
-            self.points.face_color = property
-            self.points.face_color_cycle = colors or distinct_colors
-
-    def update(self):
-        if self.layers:
-            pos = self.get_positions()
-            self.points.data = pos
-            self.points.properties = self.datablock.properties.data
-
-            vectors = self.get_vectors().values()
-            for layer, vector in zip(self.vectors, vectors):
-                layer.data = vector
diff --git a/blik/depictors/napari/pointdepictor.py b/blik/depictors/napari/pointdepictor.py
deleted file mode 100644
index fe47cac3..00000000
--- a/blik/depictors/napari/pointdepictor.py
+++ /dev/null
@@ -1,14 +0,0 @@
-from .naparidepictor import NapariDepictor
-
-
-class PointDepictor(NapariDepictor):
-    def depict(self):
-        pkwargs = {'size': 3}
-
-        self._make_points_layer(self.datablock.zyx,
-                                name=f'{self.name}',
-                                scale=self.datablock.pixel_size,
-                                **pkwargs)
-
-    def changed(self, event):
-        self.datablock.data = self.layers[0].data
diff --git a/blik/depictors/pyqtgraph/__init__.py b/blik/depictors/pyqtgraph/__init__.py
deleted file mode 100644
index 85ea6d29..00000000
--- a/blik/depictors/pyqtgraph/__init__.py
+++ /dev/null
@@ -1,2 +0,0 @@
-from .plotdepictor import PlotDepictor
-from .propertyplotdepictor import PropertyPlotDepictor
diff --git a/blik/depictors/pyqtgraph/plotdepictor.py b/blik/depictors/pyqtgraph/plotdepictor.py
deleted file mode 100644
index edef9f8c..00000000
--- a/blik/depictors/pyqtgraph/plotdepictor.py
+++ /dev/null
@@ -1,18 +0,0 @@
-from pyqtgraph import PlotItem
-
-from ..depictor import Depictor
-
-
-class PlotDepictor(Depictor):
-    def __init__(self, datablock):
-        super().__init__(datablock)
-        self.plot = PlotItem()
-        self.plot.addLegend()
-
-    def add_line(self, y, name, x=None, color='w'):
-        if x is None:
-            x = range(len(y))
-        self.plot.plot(x, y, name=name, pen=color)
-
-    def purge(self):
-        self.plot = PlotItem()
diff --git a/blik/depictors/pyqtgraph/propertyplotdepictor.py b/blik/depictors/pyqtgraph/propertyplotdepictor.py
deleted file mode 100644
index 9909e4a5..00000000
--- a/blik/depictors/pyqtgraph/propertyplotdepictor.py
+++ /dev/null
@@ -1,13 +0,0 @@
-from .plotdepictor import PlotDepictor
-from ...utils.colors import distinct_colors
-
-
-class PropertyPlotDepictor(PlotDepictor):
-    def __init__(self, datablock, colors=None, **kwargs):
-        super().__init__(datablock)
-        self.colors = colors or distinct_colors[:len(datablock.data.columns)]
-        self.kwargs = kwargs
-
-    def depict(self):
-        for (colname, data), color in zip(self.datablock.items(), self.colors):
-            self.add_line(data, name=colname, color=color, **self.kwargs)
diff --git a/blik/gui/__init__.py b/blik/gui/__init__.py
deleted file mode 100644
index 2069fefd..00000000
--- a/blik/gui/__init__.py
+++ /dev/null
@@ -1,1 +0,0 @@
-from .viewer import Viewer
diff --git a/blik/gui/viewer.py b/blik/gui/viewer.py
deleted file mode 100644
index 7404fdae..00000000
--- a/blik/gui/viewer.py
+++ /dev/null
@@ -1,159 +0,0 @@
-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
-    and implements widgets for DataSet functionality
-    """
-    def __init__(self, dataset):
-        self.dataset = dataset
-
-        self.napari_viewer = None
-        self.plots = None
-        self.blik_widget = None
-
-    def show(self, napari_viewer=None, **kwargs):
-        self.ensure_ready(napari_viewer=napari_viewer)
-        for db in self.dataset:
-            db.init_depictor(**kwargs)
-        if self.dataset.volumes:
-            self.show_volume(list(self.dataset.volumes.keys())[0])
-        if self.dataset.plots:
-            self.plots.show()
-
-    @property
-    def shown(self):
-        if self.napari_viewer and self.volume_selector:
-            return self.dataset.volumes[self.volume_selector.currentText()]
-        return None
-
-    def ensure_ready(self, napari_viewer=None):
-        if napari_viewer is not None:
-            self._init_viewer(napari_viewer)
-            self._init_plots()
-            self._init_blik_widget()
-            self._hook_keybindings()
-        # check if viewer exists and is still open
-        try:
-            self.napari_viewer.window.qt_viewer.actions()
-        except (AttributeError, RuntimeError):
-            self._init_viewer()
-            self._init_plots()
-            self._init_blik_widget()
-            self._hook_keybindings()
-
-    def _init_viewer(self, napari_viewer=None):
-        if napari_viewer is not None:
-            self.napari_viewer = napari_viewer
-        else:
-            self.napari_viewer = napari.Viewer(title='napari - Blik')
-        self.napari_viewer.scale_bar.unit = '0.1nm'
-        self.napari_viewer.scale_bar.visible = True
-        # TODO: workaround until layer issues are fixed (napari #2110)
-        self.napari_viewer.window.qt_viewer.destroyed.connect(self.dataset.purge_gui)
-
-    def _init_plots(self):
-        self.plots = GraphicsLayoutWidget()
-        self._plots_napari_widget = self.napari_viewer.window.add_dock_widget(self.plots,
-                                                                              name='Blik - Plots',
-                                                                              area='bottom')
-        # use napari hide and show methods
-        self.plots.show = self._plots_napari_widget.show
-        self.plots.hide = self._plots_napari_widget.hide
-        self.plots.hide()
-
-    def _init_blik_widget(self):
-        self.blik_widget = QWidget()
-        layout = QVBoxLayout()
-        self.blik_widget.setLayout(layout)
-
-        self.volume_selector = QComboBox(self.blik_widget)
-        self.volume_selector.addItems(self.dataset.volumes.keys())
-        self.volume_selector.currentTextChanged.connect(self.show_volume)
-        layout.addWidget(self.volume_selector)
-
-        self.plots_toggler = QPushButton('Show / Hide Plots')
-        self.plots_toggler.clicked.connect(self.toggle_plots)
-        layout.addWidget(self.plots_toggler)
-
-        self._blik_napari_widget = self.napari_viewer.window.add_dock_widget(self.blik_widget,
-                                                                           name='Blik',
-                                                                           area='left')
-        # use napari hide and show methods
-        self.blik_widget.show = self._blik_napari_widget.show
-        self.blik_widget.hide = self._blik_napari_widget.hide
-
-    def _hook_keybindings(self):
-        self.napari_viewer.bind_key('PageUp', self.previous_volume)
-        self.napari_viewer.bind_key('PageDown', self.next_volume)
-
-    def update_blik_widget(self):
-        if self.blik_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.dataset.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.dataset.napari_layers:
-                self.napari_viewer.layers.remove(layer)
-        self.plots.clear()
-
-    def show_volume(self, volume):
-        if volume is None:
-            return
-        self.volume_selector.setCurrentText(volume)
-        datablocks = self.dataset.omni + self.dataset.volumes[volume]
-
-        layers = []
-        plots = []
-        for db in datablocks:
-            for dep in db.depictors:
-                if hasattr(dep, 'layers'):
-                    if not dep.layers:
-                        dep.depict()
-                    layers.extend(dep.layers)
-                elif hasattr(dep, 'plot'):
-                    if not dep.plot.curves:
-                        dep.depict()
-                    plots.append(dep.plot)
-        layers = sorted(layers, key=lambda l: isinstance(l, napari.layers.Image), reverse=True)
-
-        self.clear_shown()
-        self.napari_viewer.layers.extend(layers)
-        for plt in plots:
-            self.plots.addItem(plt)
-
-    def previous_volume(self, viewer=None):
-        idx = self.volume_selector.currentIndex()
-        previous_idx = (idx - 1) % self.volume_selector.count()
-        self.volume_selector.setCurrentIndex(previous_idx)
-
-    def next_volume(self, viewer=None):
-        idx = self.volume_selector.currentIndex()
-        next_idx = (idx + 1) % self.volume_selector.count()
-        self.volume_selector.setCurrentIndex(next_idx)
-
-    def toggle_plots(self):
-        if self.plots.isVisible():
-            self.plots.hide()
-        else:
-            self.plots.show()
diff --git a/blik/io_/__init__.py b/blik/io_/__init__.py
deleted file mode 100644
index 2afb3694..00000000
--- a/blik/io_/__init__.py
+++ /dev/null
@@ -1,2 +0,0 @@
-from .reading import read, find_files
-from .writing import write
diff --git a/blik/io_/reading/__init__.py b/blik/io_/reading/__init__.py
deleted file mode 100644
index 258c3ee9..00000000
--- a/blik/io_/reading/__init__.py
+++ /dev/null
@@ -1,1 +0,0 @@
-from .main import read, find_files
diff --git a/blik/io_/reading/box.py b/blik/io_/reading/box.py
deleted file mode 100644
index c4c5188d..00000000
--- a/blik/io_/reading/box.py
+++ /dev/null
@@ -1,11 +0,0 @@
-import numpy as np
-
-from ..utils import guess_name
-from ...datablocks import ParticleBlock
-
-
-def read_box(box_path, name_regex=None, pixel_size=1, **kwargs):
-    data = np.loadtxt(box_path)
-    orientations = np.tile(np.identity(3), (len(data), 1, 1))
-    name = guess_name(box_path, name_regex)
-    return ParticleBlock(data, orientations, name=name, pixel_size=pixel_size)
diff --git a/blik/io_/reading/cbox.py b/blik/io_/reading/cbox.py
deleted file mode 100644
index 094272a2..00000000
--- a/blik/io_/reading/cbox.py
+++ /dev/null
@@ -1,13 +0,0 @@
-import numpy as np
-import starfile
-
-from ..utils import guess_name
-from ...datablocks import ParticleBlock
-
-
-def read_cbox(cbox_path, name_regex=None, pixel_size=None, **kwargs):
-    data = starfile.read(cbox_path)['cryolo']
-    coords = data[[f'Coordinate{axis}' for axis in 'XYZ']].to_numpy()
-    orientations = np.tile(np.identity(3), (len(data), 1, 1))
-    name = guess_name(cbox_path, name_regex)
-    return ParticleBlock(coords, orientations, name=name, pixel_size=pixel_size)
diff --git a/blik/io_/reading/em.py b/blik/io_/reading/em.py
deleted file mode 100644
index a701b1dc..00000000
--- a/blik/io_/reading/em.py
+++ /dev/null
@@ -1,31 +0,0 @@
-import logging
-
-import emfile
-import dask.array as da
-import numpy as np
-
-from ...datablocks import ImageBlock
-from ..utils import guess_name
-
-
-logger = logging.getLogger(__name__)
-
-
-def read_em(image_path, name_regex=None, lazy=True, **kwargs):
-    """
-    read an em file and return an ImageBlock
-    """
-    name = guess_name(image_path, name_regex)
-
-    header, data = emfile.read(image_path, mmap=True)
-
-    if lazy:
-        data = da.from_array(data)
-    else:
-        data = np.asarray(data)
-
-    pixel_size = header['OBJ']
-
-    ib = ImageBlock(data=data, pixel_size=pixel_size, name=name)
-    logger.debug(f'succesfully read "{image_path}", {lazy=}')
-    return ib
diff --git a/blik/io_/reading/main.py b/blik/io_/reading/main.py
deleted file mode 100644
index 3a4ca66d..00000000
--- a/blik/io_/reading/main.py
+++ /dev/null
@@ -1,182 +0,0 @@
-from collections import defaultdict
-from itertools import zip_longest
-from pathlib import Path
-import logging
-
-from .star import read_star
-from .mrc import read_mrc
-from .em import read_em
-from .tbl import read_tbl
-from .box import read_box
-from .cbox import read_cbox
-
-from ..utils import ParseError
-
-from ...dataset import DataSet
-from ...datablocks import DataBlock
-from ...utils import listify, inherit_signature
-
-
-logger = logging.getLogger(__name__)
-
-# a mapping of file extensions to readers, tuple map to tuples (don't forget trailing comma!):
-#   - multiple extensions values in the keys use the same readers
-#   - multiple readers are called in order from highest to lowers in case previous ones fail
-# TODO: put this directly in the readers to make it plug and play?
-readers = {
-    ('.star',): (read_star,),
-    ('.mrc', '.mrcs', '.map'): (read_mrc,),
-    ('.em',): (read_em,),
-    ('.tbl',): (read_tbl,),
-    ('.box',): (read_box,),
-    ('.cbox',): (read_cbox,),
-}
-
-known_formats = [ext for formats in readers for ext in formats]
-
-
-def read_file(file_path, **kwargs):
-    """
-    read a single file with the appropriate parser and return a list of datablocks
-    """
-    for ext, funcs in readers.items():
-        if file_path.suffix in ext:
-            for func in funcs:
-                try:
-                    datablocks = listify(func(file_path, **kwargs))
-                    for db in datablocks:
-                        db.file_path = file_path
-                    return datablocks
-                except ParseError:
-                    # this will be raised by individual readers when the file can't be read.
-                    # Keep trying until all options are exhausted
-                    continue
-    raise ParseError(f'could not read {file_path}')
-
-
-def root_relative_glob(glob):
-    return str(Path(glob).expanduser().resolve().relative_to('/'))
-
-
-def expand_globs(globs):
-    """
-    expand globs to single files
-    """
-    globs = listify(globs)
-    for glob in globs:
-        glob = root_relative_glob(glob)
-        yield from Path('/').glob(glob)
-
-
-def filter_readable(paths):
-    for path in paths:
-        if path.is_file() and path.suffix in known_formats:
-            yield path
-
-
-def find_files(globs):
-    """
-    take a glob pattern or iterable thereof and find all readable files that match it
-    """
-    files = expand_globs(globs)
-    readable = filter_readable(files)
-
-    yield from readable
-
-
-def read_files(*globs,
-         name='DataSet',
-         mode=None,
-         name_regex=None,
-         pixel_size=None,
-         strict=False,
-         lazy=True,
-         **kwargs):
-    r"""
-    Read any number of paths or glob patterns and construct a DataSet accordingly.
-
-    DataSet and Datablock construction arguments:
-        name: the name of the DataSet (default: DataSet)
-        mode: how to arrange DataBlocks into volumes. By default tries to guess.
-            - lone: each datablock in a separate volume
-            - zip_by_type: one of each datablock type per volume
-            - bunch: all datablocks in a single volume
-        name_regex: a regex used to infer DataBlock names from paths. For example:
-                    'Protein_\d+' will match 'MyProtein_10.star' and 'MyProtein_001.mrc'
-                    and name the respective DataBlocks 'Protein_10' and 'Protein_01'
-        pixel_size: manually set the pixel size (overrides the one read from file)
-    File reading arguments:
-        strict: if set to true, immediately fail if a matched filename cannot be read by Blik
-    Performance arguments:
-        lazy: read data lazily (if possible)
-    """
-    # if changing the signature of this function, change the one in `__main__.cli` as well!
-    modes = ('lone', 'zip_by_type', 'bunch')
-
-    if mode is not None and mode not in modes:
-        raise ValueError(f'mode can only be one of {modes}')
-
-    datablocks = []
-    for file in find_files(globs):
-        try:
-            logger.info(f'attempting to read "{file}"')
-            datablocks.extend(read_file(file, name_regex=name_regex, pixel_size=pixel_size,
-                                        lazy=lazy, **kwargs))
-        except ParseError as e:
-            logger.info(f'failed to read "{file}": {e}')
-            if strict:
-                raise
-    if not datablocks and strict:
-        raise ParseError(f'could not read any data from {globs}')
-
-    datablocks_by_type = defaultdict(list)
-    for db in datablocks:
-        datablocks_by_type[type(db)].append(db)
-
-    if mode is None:
-        # check if there are multiple types and lengths are all the same
-        if len(datablocks_by_type) > 1:
-            count_per_type = [len(db_type) for db_type in datablocks_by_type.values()]
-            if len(set(count_per_type)) == 1:
-                mode = 'zip_by_type'
-            else:
-                mode = 'lone'
-        else:
-            mode = 'lone'
-
-    if mode == 'lone':
-        for db in datablocks:
-            db.volume = db.name
-    elif mode == 'bunch':
-        pass
-    elif mode == 'zip_by_type':
-        # TODO: this is quite finnicky. We need a smarter way of handling volumes
-        for lst in datablocks_by_type.values():
-            lst.sort()
-        for dbs in zip_longest(*datablocks_by_type.values()):
-            for db in dbs:
-                db.volume = dbs[0].name
-
-    return DataSet(datablocks, name=name)
-
-
-@inherit_signature(read_files, DataSet, ignore_args=['name', 'datablocks', 'view_of'], add_args={'name': None})
-def read(*args, **kwargs):
-    """
-    Generate a DataSet from an input object or by reading from paths.
-    Additionally to all the arguments of `read_files`, this accepts DataSets and DataBlocks
-    """
-    datablocks = []
-    globs = []
-    for obj in args:
-        obj_list = listify(obj)
-        for item in obj_list:
-            if isinstance(item, DataBlock):
-                datablocks.append(item)
-            elif isinstance(item, (Path, str)):
-                globs.append(item)
-            else:
-                raise ValueError(f'cannot read type "{type(obj)}"')
-    dataset = read_files(*globs, **kwargs)
-    dataset.extend(datablocks)
-    return dataset
diff --git a/blik/io_/reading/mrc.py b/blik/io_/reading/mrc.py
deleted file mode 100644
index 459fb432..00000000
--- a/blik/io_/reading/mrc.py
+++ /dev/null
@@ -1,31 +0,0 @@
-import logging
-
-import mrcfile
-import dask.array as da
-import numpy as np
-
-from ...datablocks import ImageBlock
-from ..utils import guess_name
-from numpy.lib.recfunctions import structured_to_unstructured
-
-
-logger = logging.getLogger(__name__)
-
-
-def read_mrc(image_path, name_regex=None, lazy=True, **kwargs):
-    """
-    read an mrc file and return an ImageBlock
-    """
-    name = guess_name(image_path, name_regex)
-
-    with mrcfile.mmap(image_path) as mrc:
-        if lazy:
-            data = da.from_array(mrc.data)
-        else:
-            data = np.asarray(mrc.data)
-
-        pixel_size = structured_to_unstructured(mrc.voxel_size)[::-1]
-
-    ib = ImageBlock(data=data, pixel_size=pixel_size, name=name)
-    logger.debug(f'succesfully read "{image_path}", {lazy=}')
-    return ib
diff --git a/blik/io_/reading/star.py b/blik/io_/reading/star.py
deleted file mode 100644
index 4a493ed3..00000000
--- a/blik/io_/reading/star.py
+++ /dev/null
@@ -1,130 +0,0 @@
-import numpy as np
-import pandas as pd
-import eulerangles
-import starfile
-
-from ..utils import guess_name, ParseError, rotangle2matrix
-from ...datablocks import ParticleBlock
-
-
-coord_headings = [f'rlnCoordinate{axis}' for axis in 'XYZ']
-euler_headings = {
-    3: [f'rlnAngle{angle}' for angle in ('Rot', 'Tilt', 'Psi')],
-    2: 'rlnAnglePsi'
-}
-shift_headings = {
-    'RELION 3.0': [f'rlnOrigin{axis}' for axis in 'XYZ'],
-    'RELION 3.1': [f'rlnOrigin{axis}Angst' for axis in 'XYZ']
-}
-
-pixel_size_headings = {
-    'RELION 3.0': ['rlnDetectorPixelSize'],
-    'RELION 3.1': ['rlnImagePixelSize']
-}
-micrograph_name_heading = 'rlnMicrographName'
-
-
-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
-
-    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)
-
-        coords = df_volume[coord_headings[:dim]].to_numpy(dtype=float)
-        shifts = df_volume.get(shift_headings[mode][:dim], pd.Series([0.0])).to_numpy()
-        px_size = np.array(pixel_size or df_volume.get(pixel_size_headings[mode], pd.Series([1.0])).to_numpy())
-        # only relion 3.1 has shifts in angstroms
-        if mode == 'RELION 3.1':
-            shifts = shifts / px_size
-        coords -= shifts
-
-        eulers = df_volume.get(euler_headings[dim], pd.Series([0])).to_numpy()
-        if dim == 3:
-            rotation_matrices = euler2matrix(eulers)
-        else:
-            rotation_matrices = rotangle2matrix(eulers)
-
-        properties = {key: df_volume[key].to_numpy() for key in df.columns}
-
-        # TODO: better way to handle pizel size? Now we can only account for uniform size
-        pixel_size = px_size.flatten()[0]
-        pixel_size = [pixel_size] * dim
-        particleblocks.append(ParticleBlock(positions_data=coords,
-                                            orientations_data=rotation_matrices,
-                                            properties_data=properties,
-                                            properties_protected=list(properties.keys()),
-                                            pixel_size=np.array(pixel_size),
-                                            name=name))
-
-    return particleblocks
-
-
-def euler2matrix(euler_angles):
-    """
-    Convert (n, 3) array of RELION euler angles to rotation matrices
-    Resulting rotation matrices rotate references into particles
-    """
-    rotation_matrices = eulerangles.euler2matrix(euler_angles,
-                                                 axes='zyz',
-                                                 intrinsic=True,
-                                                 right_handed_rotation=True)
-
-    rotation_matrices = rotation_matrices.swapaxes(-2, -1)
-    return rotation_matrices
-
-
-def parse_relion30(raw_data, **kwargs):
-    """
-    Attempt to parse raw data dict from starfile.read as a RELION 3.0 style star file
-    """
-    if len(raw_data.values()) > 1:
-        raise ParseError("Cannot parse as RELION 3.0 format STAR file")
-
-    df = list(raw_data.values())[0]
-    return extract_data(df, mode='RELION 3.0', **kwargs)
-
-
-def parse_relion31(raw_data, **kwargs):
-    """
-    Attempt to parse raw data from starfile.read as a RELION 3.1 style star file
-    """
-    if list(raw_data.keys()) != ['optics', 'particles']:
-        raise ParseError("Cannot parse as RELION 3.1 format STAR file")
-
-    df = raw_data['particles'].merge(raw_data['optics'])
-    return extract_data(df, mode='RELION 3.1', **kwargs)
-
-
-reader_functions = {
-    'relion_3.0': parse_relion30,
-    'relion_3.1': parse_relion31,
-}
-
-
-def read_star(star_path, **kwargs):
-    """
-    Dispatch function for reading a starfile into one or multiple ParticleBlocks
-    """
-    try:
-        raw_data = starfile.read(star_path, always_dict=True)
-    except pd.errors.EmptyDataError:  # raised sometimes by .star files with completely different data
-        raise ParseError(f'the contents of {star_path} have the wrong format')
-
-    failed_reader_functions = []
-    for style, reader_function in reader_functions.items():
-        try:
-            particle_blocks = reader_function(raw_data, star_path=star_path, **kwargs)
-            return particle_blocks
-        except ParseError:
-            failed_reader_functions.append((style, reader_function))
-    raise ParseError(f'Failed to parse {star_path} using {failed_reader_functions}')
diff --git a/blik/io_/reading/tbl.py b/blik/io_/reading/tbl.py
deleted file mode 100644
index b1892cac..00000000
--- a/blik/io_/reading/tbl.py
+++ /dev/null
@@ -1,76 +0,0 @@
-import numpy as np
-import pandas as pd
-import dynamotable
-import eulerangles
-
-from ..utils import rotangle2matrix, guess_name
-from ...datablocks import ParticleBlock
-
-
-def euler2matrix_dynamo(euler_angles):
-    """Convert (n, 3) array of Dynamo euler angles to rotation matrices
-    Resulting rotation matrices rotate references into particles
-    """
-    rotation_matrices = eulerangles.euler2matrix(euler_angles,
-                                                 axes='zxz',
-                                                 intrinsic=False,
-                                                 right_handed_rotation=True)
-
-    rotation_matrices = rotation_matrices.swapaxes(-2, -1)
-    return rotation_matrices
-
-
-def name_from_volume(volume_identifier, name_regex=None):
-    """Generate ParticleBlock name from volume identifier from dataframe
-    """
-    if isinstance(volume_identifier, int):
-        return str(volume_identifier)
-    elif isinstance(volume_identifier, str):
-        return guess_name(volume_identifier, name_regex)
-
-
-def read_tbl(table_path, table_map_file=None, name_regex=None, pixel_size=None, **kwargs):
-    """
-    Reads a dynamo format table file into a list of ParticleBlocks
-    """
-    df = dynamotable.read(table_path, table_map_file)
-
-    coord_headings = ['x', 'y', 'z']
-    shift_headings = ['dx', 'dy', 'dz']
-    euler_headings = {3: ['tdrot', 'tilt', 'narot'], 2: 'tilt'}  # TODO: 2d column name might be wrong!
-
-    split_on = 'tomo'
-    if 'tomo_file' in df.columns:
-        split_on = 'tomo_file'
-
-    particleblocks = []
-
-    if coord_headings[-1] in df.columns:
-        dim = 3
-    else:
-        dim = 2
-    for volume, df_volume in df.groupby(split_on):
-        name = name_from_volume(volume, name_regex)
-        coords = df_volume[coord_headings[:dim]].to_numpy(dtype=float)
-        shifts = df_volume.get(shift_headings[:dim], pd.Series([0.0])).to_numpy()
-        coords += shifts
-
-        eulers = df_volume.get(euler_headings[dim], pd.Series([0])).to_numpy()
-        if dim == 3:
-            rotation_matrices = euler2matrix_dynamo(eulers)
-        else:
-            rotation_matrices = rotangle2matrix(eulers)
-
-        properties = {key: df_volume[key].to_numpy() for key in df.columns}
-
-        if pixel_size is None:
-            pixel_size = np.array([1] * dim)
-
-        particleblocks.append(ParticleBlock(positions_data=coords,
-                                            orientations_data=rotation_matrices,
-                                            properties_data=properties,
-                                            properties_protected=list(properties.keys()),
-                                            pixel_size=pixel_size,
-                                            name=name))
-
-    return particleblocks
diff --git a/blik/io_/utils/__init__.py b/blik/io_/utils/__init__.py
deleted file mode 100644
index 25b7fd6b..00000000
--- a/blik/io_/utils/__init__.py
+++ /dev/null
@@ -1,2 +0,0 @@
-from .generic import guess_name, ParseError
-from .angle_conversion import rotangle2matrix
diff --git a/blik/io_/utils/angle_conversion.py b/blik/io_/utils/angle_conversion.py
deleted file mode 100644
index b8598af8..00000000
--- a/blik/io_/utils/angle_conversion.py
+++ /dev/null
@@ -1,13 +0,0 @@
-import numpy as np
-
-
-def rotangle2matrix(angle):
-    rad = np.deg2rad(np.array(angle).reshape(-1))
-    matrices = np.zeros((rad.shape[0], 2, 2), dtype=float)
-    cos = np.cos(rad)
-    sin = np.sin(rad)
-    matrices[:, 0, 0] = cos
-    matrices[:, 0, 1] = -sin
-    matrices[:, 1, 1] = cos
-    matrices[:, 1, 0] = sin
-    return matrices.swapaxes(-2, -1)
diff --git a/blik/io_/utils/generic.py b/blik/io_/utils/generic.py
deleted file mode 100644
index 06a78026..00000000
--- a/blik/io_/utils/generic.py
+++ /dev/null
@@ -1,27 +0,0 @@
-import re
-
-
-class ParseError(RuntimeError):
-    pass
-
-
-# a list of commonly used base names for starfiles in regex form
-common_name_regexes = (
-    r'TS_\d+',
-    r'\d+',
-)
-
-
-def guess_name(string, name_regex=None):
-    """
-    guess an appropriate name based on the input string
-    and a list of regexes in order of priority
-    """
-    regexes = list(common_name_regexes)
-    if name_regex is not None:
-        regexes.insert(0, name_regex)
-    for regex in regexes:
-        if match := re.search(regex, str(string)):
-            return match.group(0)
-    else:
-        return None
diff --git a/blik/io_/writing/__init__.py b/blik/io_/writing/__init__.py
deleted file mode 100644
index a4bfba4f..00000000
--- a/blik/io_/writing/__init__.py
+++ /dev/null
@@ -1,1 +0,0 @@
-from .write import write
diff --git a/blik/io_/writing/em/__init__.py b/blik/io_/writing/em/__init__.py
deleted file mode 100644
index b2b78877..00000000
--- a/blik/io_/writing/em/__init__.py
+++ /dev/null
@@ -1,1 +0,0 @@
-from .em import write_em
diff --git a/blik/io_/writing/em/em.py b/blik/io_/writing/em/em.py
deleted file mode 100644
index a357baa4..00000000
--- a/blik/io_/writing/em/em.py
+++ /dev/null
@@ -1,10 +0,0 @@
-import emfile
-
-
-def write_em(imageblock, file_path, overwrite=False):
-    """
-    write an image block to disk as an .em file
-    """
-    if not file_path.endswith('.em'):
-        file_path = file_path + '.em'
-    emfile.write(file_path, imageblock.data, header_params={}, overwrite=overwrite)
diff --git a/blik/io_/writing/mrc/__init__.py b/blik/io_/writing/mrc/__init__.py
deleted file mode 100644
index 340ae89e..00000000
--- a/blik/io_/writing/mrc/__init__.py
+++ /dev/null
@@ -1,1 +0,0 @@
-from .mrc import write_mrc
diff --git a/blik/io_/writing/mrc/mrc.py b/blik/io_/writing/mrc/mrc.py
deleted file mode 100644
index b35750a9..00000000
--- a/blik/io_/writing/mrc/mrc.py
+++ /dev/null
@@ -1,10 +0,0 @@
-import mrcfile
-
-
-def write_mrc(imageblock, file_path, overwrite=False):
-    """
-    write an image block to disk as an .mrc file
-    """
-    if not file_path.endswith('.mrc'):
-        file_path = file_path + '.mrc'
-    mrcfile.new(file_path, imageblock.data, overwrite=overwrite)
diff --git a/blik/io_/writing/star/__init__.py b/blik/io_/writing/star/__init__.py
deleted file mode 100644
index 86e03b43..00000000
--- a/blik/io_/writing/star/__init__.py
+++ /dev/null
@@ -1,1 +0,0 @@
-from .star import write_star
diff --git a/blik/io_/writing/star/star.py b/blik/io_/writing/star/star.py
deleted file mode 100644
index 5d6b7010..00000000
--- a/blik/io_/writing/star/star.py
+++ /dev/null
@@ -1,68 +0,0 @@
-import pandas as pd
-import starfile
-import eulerangles
-
-
-def matrix2euler(rotation_matrices):
-    """
-    convert euler matrices to angles
-    """
-    # invert them so that we return matrices which transform reference onto particle
-    rotation_matrices = rotation_matrices.transpose((0, 2, 1))
-
-    euler_angles = eulerangles.matrix2euler(rotation_matrices,
-                                            axes='zyz',
-                                            intrinsic=True,
-                                            right_handed_rotation=True)
-    return euler_angles
-
-
-def matrix2rotangle(rotation_matrices):
-    raise NotImplementedError
-
-
-coord_headings = {
-    '3d': [f'rlnCoordinate{axis}' for axis in 'XYZ'],
-    '2d': [f'rlnCoordinate{axis}' for axis in 'XY']
-}
-
-euler_headings = [f'rlnAngle{angle}' for angle in ('Rot', 'Tilt', 'Psi')]
-
-angle_heading = ['rlnAnglePsi']
-
-
-def write_star(particleblock, file_path, data_colums=None, overwrite=False):
-    """
-    write a particle block to disk as a .star file
-    data_colums: particleblock properties to include as columns
-    """
-    coords = particleblock.positions.data
-    dim = 3 if particleblock.is_3D else 2
-    orientation_matrices = particleblock.orientations.data
-    if data_colums is not None:
-        properties = particleblock.properties.data[data_colums].to_numpy()
-    else:
-        data_colums = []
-        properties = []
-
-    df = pd.DataFrame()
-
-    if dim == 3:
-        x, y, z = coords.T
-        eulers = matrix2euler(orientation_matrices)
-        ang1, ang2, ang3 = eulers.T
-        column_names = coord_headings['3d'] + euler_headings + data_colums
-        for name, values in zip(column_names, [x, y, z, ang1, ang2, ang3] + properties):
-            df[name] = values
-    else:
-        x, y = coords.T
-        rotangle = matrix2rotangle(orientation_matrices)
-        psi = rotangle.T
-        column_names = coord_headings['2d'] + angle_heading + data_colums
-        for name, values in zip(column_names, [x, y, psi] + properties):
-            df[name] = values
-
-    path = file_path
-    if not path.suffix:
-        path = str(path) + '.star'
-    starfile.write(df, path, overwrite=overwrite)
diff --git a/blik/io_/writing/write.py b/blik/io_/writing/write.py
deleted file mode 100644
index 2ccde32d..00000000
--- a/blik/io_/writing/write.py
+++ /dev/null
@@ -1,65 +0,0 @@
-from collections import Counter
-from pathlib import Path
-
-from ...datablocks import ImageBlock, ParticleBlock
-
-from ...utils import listify
-from .mrc import write_mrc
-from .em import write_em
-from .star import write_star
-
-
-writers = {
-    (ImageBlock,): (write_mrc, write_em,),
-    (ParticleBlock,): (write_star,),
-}
-
-
-def get_writer(datablock):
-    for dbtypes, writer_funcs in writers.items():
-        if type(datablock) in dbtypes:
-            # TODO: implement some logic
-            return writer_funcs[0]
-    raise ValueError(f'could not find a writer for {type(datablock)}')
-
-
-def write(datablocks, paths, overwrite=False, strict=False, **kwargs):
-    """
-    wite a list of datablocks to disk
-    """
-    # sanitize input
-    datablocks = listify(datablocks)
-    paths = [Path(path).expanduser().resolve() for path in listify(paths)]
-
-    # check if numbers match
-    if len(datablocks) != len(paths) and len(datablocks):
-        # were we given a dir path?
-        if len(paths) != 1:
-            raise NotImplementedError
-        elif any(path.is_file() for path in paths):
-            raise ValueError(f'number of datablocks and paths must be equal; '
-                             f'got {len(datablocks)} datablocks and {len(paths)} paths')
-        else:
-            # i guess it's a dir. convert it to paths
-            # TODO: not great with a single datablock
-            dir_path = paths[0]
-            dir_path.mkdir(parents=True, exist_ok=True)
-            paths = []
-            count_same_name = Counter()
-            for datablock in datablocks:
-                count_same_name[datablock.name] += 1
-                if idx := count_same_name[datablock.name] > 1:
-                    path = dir_path / datablock.name + str(idx)
-                else:
-                    path = dir_path / datablock.name
-                paths.append(path)
-
-    for datablock, path in zip(datablocks, paths):
-        if not overwrite and path.exists():
-            raise ValueError('file {path} exists!')
-        try:
-            writer = get_writer(datablock)
-            writer(datablock, path, overwrite=overwrite, **kwargs)
-        except ValueError:
-            if strict:
-                raise
diff --git a/blik/napari.yaml b/blik/napari.yaml
new file mode 100644
index 00000000..6d83c817
--- /dev/null
+++ b/blik/napari.yaml
@@ -0,0 +1,39 @@
+name: blik
+display_name: blik
+contributions:
+  commands:
+    - id: blik.get_reader
+      python_name: blik.reader:get_reader
+      title: Open data with blik
+    - id: blik.volume_selector
+      python_name: blik.widgets.volume_selector:volume_selector
+      title: Create blik widget
+    - id: blik.file_reader
+      python_name: blik.widgets.file_reader:file_reader
+      title: Create blik widget
+    - id: blik.bandpass_filter
+      python_name: blik.widgets.filter:bandpass_filter
+      title: Create bandpass filter widget
+    - id: blik.gaussian_filter
+      python_name: blik.widgets.filter:gaussian_filter
+      title: Create gaussian filter widget
+  readers:
+    - command: blik.get_reader
+      filename_patterns:
+        - '*.mrc'
+        - '*.mrcs'
+        - '*.st'
+        - '*.em'
+        - '*.star'
+        - '*.tbl'
+        - '*.box'
+        - '*.cbox'
+  widgets:
+    - command: blik.volume_selector
+      display_name: volume selector
+    - command: blik.file_reader
+      display_name: file reader
+    - command: blik.bandpass_filter
+      display_name: bandpass filter
+    - command: blik.gaussian_filter
+      display_name: gaussian filter
diff --git a/blik/reader.py b/blik/reader.py
new file mode 100644
index 00000000..b199626f
--- /dev/null
+++ b/blik/reader.py
@@ -0,0 +1,83 @@
+import numpy as np
+from scipy.spatial.transform import Rotation
+
+from naaf import read
+from naaf.data import Particles, Image
+from naaf.utils.constants import Naaf
+
+
+def get_reader(path):
+    return read_layers
+
+
+def read_particles(particles):
+    layers = []
+    coords = np.asarray(particles.data[Naaf.COORD_HEADERS])[:, ::-1]  # order is zyx in napari
+    rot = Rotation.concatenate(particles.data[Naaf.ROT_HEADER])
+    features = particles.data.drop(columns=Naaf.ALL_HEADERS, errors='ignore')
+
+    pts = (
+        coords,
+        dict(
+            name=f'{particles.name} - particle positions',
+            features=features,
+            face_color='teal',
+            size=10,
+            edge_width=0,
+            scale=particles.pixel_size,
+            shading='spherical',
+            metadata={'blik_volume': particles.name}
+        ),
+        'points',
+    )
+    layers.append(pts)
+
+    for idx, (ax, color) in enumerate(zip('zyx', 'rgb')):  # order is zyx in napari
+        basis = np.zeros(3)
+        basis[idx] = 1
+        basis_rot = rot.apply(basis)[:, ::-1]  # order is zyx in napari
+        vec_data = np.stack([coords, basis_rot], axis=1)
+        vec = (
+            vec_data,
+            dict(
+                name=f'{particles.name} - particle orientations ({ax})',
+                edge_color=color,
+                length=10,
+                scale=particles.pixel_size,
+                metadata={'blik_volume': particles.name}
+            ),
+            'vectors',
+        )
+        layers.append(vec)
+
+    return layers
+
+
+def read_image(image):
+    return (
+        image.data,
+        dict(
+            name=f'{image.name} - image',
+            scale=image.pixel_size,
+            metadata={'blik_volume': image.name, 'stack': image.stack},
+            interpolation='spline36',
+        ),
+        'image',
+    )
+
+
+def read_layers(*paths, **kwargs):
+    data_list = read(*paths, **kwargs)
+    layers = []
+    for data in data_list:
+        if isinstance(data, Particles):
+            layers.extend(read_particles(data))
+        elif isinstance(data, Image):
+            layers.append(read_image(data))
+
+    for lay in layers:
+        lay[1]['visible'] = False  # speed up loading
+        if lay[1]['scale'] is None:
+            # fix until napari#4295 is merged
+            lay[1]['scale'] = [1, 1, 1]
+    return layers or None
diff --git a/blik/utils/__init__.py b/blik/utils/__init__.py
deleted file mode 100644
index d1bc5a8d..00000000
--- a/blik/utils/__init__.py
+++ /dev/null
@@ -1,4 +0,0 @@
-from .generic import listify, inherit_signature
-from .linalg_helper import align_vectors, dim_names_to_indexes
-from .colors import distinct_colors, faded_grey
-from .dispatchlist import DispatchList
diff --git a/blik/utils/colors.py b/blik/utils/colors.py
deleted file mode 100644
index e34e16d1..00000000
--- a/blik/utils/colors.py
+++ /dev/null
@@ -1,28 +0,0 @@
-# flake8: noqa
-# https://sashamaps.net/docs/resources/20-colors/
-distinct_colors = [
-    [230,  25,  75, 255],
-    [ 60, 180,  75, 255],
-    [255, 225,  25, 255],
-    [  0, 130, 200, 255],
-    [245, 130,  48, 255],
-    [145,  30, 180, 255],
-    [ 70, 240, 240, 255],
-    [240,  50, 230, 255],
-    [210, 245,  60, 255],
-    [250, 190, 212, 255],
-    [  0, 128, 128, 255],
-    [220, 190, 255, 255],
-    [170, 110,  40, 255],
-    [255, 250, 200, 255],
-    [128,   0,   0, 255],
-    [170, 255, 195, 255],
-    [128, 128,   0, 255],
-    [255, 215, 180, 255],
-    [  0,   0, 128, 255],
-    [128, 128, 128, 255],
-    [255, 255, 255, 255],
-    [  0,   0,   0, 255],
-]
-
-faded_grey = [120, 120, 120, 50]
diff --git a/blik/utils/dispatchlist.py b/blik/utils/dispatchlist.py
deleted file mode 100644
index f9d40b85..00000000
--- a/blik/utils/dispatchlist.py
+++ /dev/null
@@ -1,189 +0,0 @@
-class DispatchList:
-    """
-    list-like class that can dispatch getitem, getattr, setattr and calls to its contents,
-    while retaining its original structure intact.
-
-    methods that starting with a `_` and attributes provided in the _ignore_dispatch class attribute
-    will get around this. Subclasses can define a new list of _ignore_dispatch: the parent classes values
-    will still be checked against.
-
-    By default, getitem acts on the list itself. To force dispatch to the most nested level,
-    use it on the `.disp` attribute
-    """
-    _ignore_dispatch = ('disp',)
-
-    def __init__(self, iterable=(), parent=None):
-        # avoid circular import
-        from .generic import listify
-        self._data = listify(iterable)
-        if parent is None:
-            parent = self
-        self._parent = parent
-        self.disp = ItemDispatcher(self)
-
-    def __newlike__(self, *args, **kwargs):
-        return type(self)(*args, **kwargs)
-
-    def __newchild__(self, *args, **kwargs):
-        return self.__newlike__(*args, parent=self._parent, **kwargs)
-
-    def __getattribute__(self, name):
-        try:
-            return super().__getattribute__(name)
-        except AttributeError:
-            try:
-                return self.__getattr__(name)
-            except AttributeError:
-                raise AttributeError(f"{type(self).__name__} and its contents have no attribute '{name}'")
-
-    @classmethod
-    def _dispatchable(cls, name):
-        """
-        returns whether or not to dispatch the attribute call
-        """
-        if name.startswith('_'):
-            return False
-        else:
-            ignore = []
-            for parent in cls.mro():
-                ignore.extend(getattr(parent, '_ignore_dispatch', ()))
-            if name in ignore:
-                return False
-        return True
-
-    def __getattr__(self, name):
-        # ignore under and (d)under methods
-        if not self._dispatchable(name):
-            raise AttributeError(f"{type(self).__name__} has no attribute '{name}'")
-        return self.__newchild__([el.__getattribute__(name) for el in self._data])
-
-    def __setattr__(self, name, value):
-        # ignore under and (d)under methods
-        if not self._dispatchable(name):
-            return super().__setattr__(name, value)
-
-        try:
-            present = self.__getattribute__(name)
-        except AttributeError:
-            present = None
-
-        if not isinstance(present, type(self)):
-            # the attribute was found at the top level, or the attribute wasn't found
-            # either way, set it to the top level (parent)
-            self._parent.__dict__[name] = value
-        else:
-            # dispatch to lower levels
-            [el.__setattr__(name, value) for el in self]
-
-    def __call__(self, *args, **kwargs):
-        return self.__newchild__([el(*args, **kwargs) for el in self])
-
-    def __iter__(self):
-        yield from self._data
-
-    def __contains__(self, item):
-        found = item in self._data
-        if not found:
-            for el in self._data:
-                try:
-                    found = item in el
-                except TypeError:
-                    found = False
-        return found
-
-    def __len__(self):
-        return len(self._data)
-
-    def __getitem__(self, key):
-        if isinstance(key, int):
-            return self._data[key]
-        else:
-            return self.__newchild__(self._data[key])
-
-    def __delitem__(self, key):
-        del self._data[key]
-
-    def __setitem__(self, key, value):
-        self._data[key] = value
-
-    def __add__(self, other):
-        if isinstance(other, DispatchList):
-            return self.__newlike__(self._data + other._data)
-        elif isinstance(other, list):
-            return self.__newlike__(self._data + other)
-        else:
-            return NotImplemented
-
-    def __iadd__(self, other):
-        if isinstance(other, DispatchList):
-            self._data += other._data
-            return self
-        elif isinstance(other, list):
-            self._data += other
-            return self
-        else:
-            return NotImplemented
-
-    def __eq__(self, other):
-        if isinstance(other, DispatchList):
-            return self._data == other._data
-        elif isinstance(other, list):
-            return self._data == other
-        else:
-            return all(item == other for item in self)
-
-    def __repr__(self):
-        return f'*{self._data.__repr__()}*'
-
-    def append(self, item):
-        self._data.append(item)
-
-    def insert(self, i, item):
-        self._data.insert(i, item)
-
-    def pop(self, i=-1):
-        return self._data.pop(i)
-
-    def reverse(self):
-        self._data.reverse()
-
-    def extend(self, other):
-        if isinstance(other, DispatchList):
-            self._data.extend(other._data)
-        elif isinstance(other, list):
-            self._data.extend(other)
-        else:
-            return NotImplemented
-
-    def index(self, item, *args):
-        return self._data.index(item, *args)
-
-    def flatten(self):
-        out = DispatchList()
-        for el in self:
-            if isinstance(el, DispatchList):
-                out.extend(el.flatten())
-            else:
-                out.append(el)
-        return out
-
-
-class ItemDispatcher:
-    def __init__(self, parent):
-        self.parent = parent
-
-    def __getitem__(self, key):
-        items = []
-        for el in self.parent:
-            if isinstance(el, DispatchList):
-                items.extend(el.disp[key])
-            else:
-                items.append(el[key])
-        return self.parent.__newchild__(items)
-
-    def __setitem__(self, key, value):
-        for el in self.parent:
-            if isinstance(el, DispatchList):
-                el.disp[key] = value
-            else:
-                el[key] = value
diff --git a/blik/utils/generic.py b/blik/utils/generic.py
deleted file mode 100644
index a1f388f3..00000000
--- a/blik/utils/generic.py
+++ /dev/null
@@ -1,63 +0,0 @@
-from collections.abc import Iterable
-from pathlib import Path
-from functools import wraps
-import inspect
-
-
-def listify(obj):
-    """
-    transform input into an appropriate list, unless already list-like
-    """
-    # avoid circular import
-    from ..datablocks import DataBlock
-    if isinstance(obj, Iterable):
-        if isinstance(obj, (str, DataBlock, Path)):
-            return [obj]
-        else:
-            return list(obj)
-    if obj is None:
-        return []
-    return [obj]
-
-
-def inherit_signature(*other_functions, ignore_args=None, add_args={}):
-    """
-    meta-decorator that copies functions' signature and docstrings onto another,
-    ignoring arguments as requested and patching docstrings together
-    """
-    ignore_args = listify(ignore_args)
-
-    # "double decorator" is needed to have a decorator with additional arguments!
-    # other_functions / func are supposed to be different: other_functions provide the signature,
-    # func is the decorated function
-    def decorator(func):
-        @wraps(func)
-        def wrapper(*args, **kwargs):
-            return func(*args, **kwargs)
-
-        all_params = []
-        for other_func in other_functions:
-            sig = inspect.signature(other_func)
-            params = list(sig.parameters.values())
-            # discard arguments
-            keep_params = []
-            for par in params:
-                if par.name not in ignore_args:
-                    keep_params.append(par)
-            all_params.extend(keep_params)
-
-        for par_name, default in add_args.items():
-            par = inspect.Parameter(name=par_name, default=default, kind=inspect.Parameter.KEYWORD_ONLY)
-            all_params.append(par)
-
-        sorted_params = sorted(all_params, key=lambda x: x.kind)
-        new_sig = sig.replace(parameters=(sorted_params))
-        wrapper.__signature__ = new_sig
-
-        sep = '-' * 80
-        funcs = [func] + list(other_functions)
-        docstr = sep.join([f.__doc__ for f in funcs if f.__doc__ is not None])
-        wrapper.__doc__ = docstr
-
-        return wrapper
-    return decorator
diff --git a/blik/utils/linalg_helper.py b/blik/utils/linalg_helper.py
deleted file mode 100644
index 355e5cba..00000000
--- a/blik/utils/linalg_helper.py
+++ /dev/null
@@ -1,50 +0,0 @@
-import numpy as np
-
-
-def align_vectors(a: np.ndarray, b: np.ndarray):
-    """
-    Find array of rotation matrices r such that r @ a = b when a and b are arrays of normalised vectors
-    implementation designed to avoid trig calls
-    based on https://iquilezles.org/www/articles/noacos/noacos.htm
-    :param a: normalised vector(s) of length 3
-    :param b: normalised vector(s) of length 3
-    :return: rotation matrix
-    """
-    # setup
-    a = a.reshape(-1, 3)
-    b = b.reshape(-1, 3)
-    n_vectors = max(a.shape[0], b.shape[0])
-
-    # cross product to find axis about which rotation should occur
-    axis = np.cross(a, b, axis=1)
-    # dot product equals cosine of angle between normalised vectors
-    cos_angle = np.einsum('ij, ij -> i', a, b)
-    # k is a constant which appears as a factor in the rotation matrix
-    k = 1 / (1 + cos_angle)
-
-    # construct rotation matrix
-    r = np.empty((n_vectors, 3, 3))
-    r[:, 0, 0] = (axis[:, 0] * axis[:, 0] * k) + cos_angle
-    r[:, 0, 1] = (axis[:, 1] * axis[:, 0] * k) - axis[:, 2]
-    r[:, 0, 2] = (axis[:, 2] * axis[:, 0] * k) + axis[:, 1]
-    r[:, 1, 0] = (axis[:, 0] * axis[:, 1] * k) + axis[:, 2]
-    r[:, 1, 1] = (axis[:, 1] * axis[:, 1] * k) + cos_angle
-    r[:, 1, 2] = (axis[:, 2] * axis[:, 1] * k) - axis[:, 0]
-    r[:, 2, 0] = (axis[:, 0] * axis[:, 2] * k) - axis[:, 1]
-    r[:, 2, 1] = (axis[:, 1] * axis[:, 2] * k) + axis[:, 0]
-    r[:, 2, 2] = (axis[:, 2] * axis[:, 2] * k) + cos_angle
-
-    return r
-
-
-def dim_names_to_indexes(names, order='xyz'):
-    """
-    Get indexes of named dimensions
-    """
-    dims_order = list(order)
-    indexes = []
-    for dim in list(names):
-        idx = dims_order.index(dim)
-        # return negative indexes to account for non-spatial dims
-        indexes.append(idx - 3)
-    return indexes
diff --git a/blik/_tests/__init__.py b/blik/widgets/__init__.py
similarity index 100%
rename from blik/_tests/__init__.py
rename to blik/widgets/__init__.py
diff --git a/blik/widgets/file_reader.py b/blik/widgets/file_reader.py
new file mode 100644
index 00000000..faa028a7
--- /dev/null
+++ b/blik/widgets/file_reader.py
@@ -0,0 +1,29 @@
+from magicgui import magic_factory
+from typing import TYPE_CHECKING, List
+from pathlib import Path
+
+from ..reader import read_layers
+
+if TYPE_CHECKING:
+    import napari
+
+
+@magic_factory(
+    call_button='Read',
+    files=dict(mode='rm'),
+    name_regex=dict(widget_type='ListEdit'),
+    names=dict(widget_type='ListEdit'),
+)
+def file_reader(
+    files: List[Path],
+    name_regex: List[str],
+    names: List[str],
+    as_dask_array: bool = True,
+) -> 'napari.types.LayerDataTuple':
+    """
+    Read files with blik.
+
+    name_regex: a regex string. Matching text will be used as name for the piece of data
+    names: only load data matching this comma separated list of names
+    """
+    return read_layers(*files, name_regex=name_regex or None, names=names or None, lazy=as_dask_array)
diff --git a/blik/widgets/filter.py b/blik/widgets/filter.py
new file mode 100644
index 00000000..d7ec6301
--- /dev/null
+++ b/blik/widgets/filter.py
@@ -0,0 +1,32 @@
+from typing import TYPE_CHECKING
+
+from magicgui import magic_factory
+from skimage.filters import butterworth, gaussian
+import numpy as np
+
+if TYPE_CHECKING:
+    import napari
+
+
+@magic_factory(
+    auto_call=False,
+    call_button=True,
+    low=dict(widget_type='FloatSlider', min=0, max=1),
+    high=dict(widget_type='FloatSlider', min=0, max=1),
+)
+def bandpass_filter(image: 'napari.layers.Image', low: float = 0.1, high: float = 0.7) -> 'napari.types.LayerDataTuple':
+    channel_axis = 0 if image.metadata['stack'] else None
+    high_pass = butterworth(np.asarray(image.data), low, high_pass=True, channel_axis=channel_axis)
+    low_pass = butterworth(high_pass, high, channel_axis=channel_axis)
+    return low_pass, dict(name=f'filtered {image.name}', scale=image.scale), 'image'
+
+
+@magic_factory(
+    auto_call=False,
+    call_button=True,
+    sigma=dict(widget_type='FloatSlider', min=0, max=10),
+)
+def gaussian_filter(image: 'napari.layers.Image', sigma: float = 1) -> 'napari.types.LayerDataTuple':
+    channel_axis = 0 if image.metadata['stack'] else None
+    filtered = gaussian(np.asarray(image.data), sigma=sigma, channel_axis=channel_axis)
+    return filtered, dict(name=f'filtered {image.name}', scale=image.scale), 'image'
diff --git a/blik/widgets/volume_selector.py b/blik/widgets/volume_selector.py
new file mode 100644
index 00000000..a58ebb39
--- /dev/null
+++ b/blik/widgets/volume_selector.py
@@ -0,0 +1,71 @@
+from magicgui import magic_factory
+from typing import TYPE_CHECKING
+from napari.utils._magicgui import find_viewer_ancestor
+
+if TYPE_CHECKING:
+    import napari
+
+
+def _get_choices(wdg):
+    """
+    generate choices for the volume dropdown based on the layers in the layerlist
+    """
+    viewer = find_viewer_ancestor(wdg.native)
+    if not viewer:
+        return []
+
+    choices = set()
+    for lay in viewer.layers:
+        try:
+            vol = lay.metadata['blik_volume']
+        except KeyError:
+            continue
+        if vol is not None:
+            choices.add(vol)
+    return sorted(list(choices))
+
+
+def _on_init(wdg):
+    """
+    hook up widget to update choices wheneven things change in the layerlist
+
+    also sets up a few things on the viewer
+    """
+    @wdg.parent_changed.connect
+    def _look_for_viewer():
+        viewer = find_viewer_ancestor(wdg.native)
+        if viewer:
+            viewer.layers.events.inserted.connect(wdg.volume.reset_choices)
+            viewer.layers.events.removed.connect(wdg.volume.reset_choices)
+
+            viewer.scale_bar.unit = '0.1nm'  # pixels are 1 Angstrom
+            viewer.scale_bar.visible = True
+
+
+@magic_factory(
+    auto_call=True,
+    call_button=False,
+    widget_init=_on_init,
+    volume=dict(widget_type='ComboBox', choices=_get_choices, nullable=True),
+)
+def volume_selector(viewer: 'napari.Viewer', volume):
+    """
+    Select which volume to display in napari and hide everything else.
+    """
+    sel = []
+    if viewer is None:
+        return
+    for layer in viewer.layers:
+        try:
+            layer_vol = layer.metadata['blik_volume']
+        except KeyError:
+            # leave untracked layers alone, and keep them in the selection if there
+            if layer in viewer.selection:
+                sel.append(layer)
+            continue
+        if layer_vol == volume:
+            layer.visible = True
+            sel.append(layer)
+        else:
+            layer.visible = False
+    viewer.layers.selection = set(sel)
diff --git a/docs/_toc.yml b/docs/_toc.yml
index 7e243c55..0346f252 100644
--- a/docs/_toc.yml
+++ b/docs/_toc.yml
@@ -2,4 +2,3 @@ format: jb-book
 root: index
 chapters:
 - file: getting_started
-- file: tips_and_tricks
diff --git a/docs/getting_started.md b/docs/getting_started.md
index fd8c7341..eebbbb7f 100644
--- a/docs/getting_started.md
+++ b/docs/getting_started.md
@@ -4,30 +4,47 @@
 
 From the command line:
 ```bash
-blik /path/to.star /path/to/mrc/files/
+napari /path/to.star /path/to/mrc/files/* [-w blik]
 ```
 
-Alternatively, use Blik from within python directly (it's designed with ipython in mind):
-```python
-import blik 
+Specifying `-w blik` will also open the volume selector widget and set up some things, such as layer visibility and the scale bar.
 
-dataset = blik.read('path_to.mrc')
-dataset.show()
+
+## Widgets
+### Volume Selector
+
+In the `napari` GUI, you'll find the `volume selector`. Select which volume to visualize with the dropdown menu, and the relevant layers will be selected and made visible.
+
+
+### File Reader
+If you need finer control over loading data, you can use the `file reader` widget. You can also start it from the command line with:
+
+```
+napari -w blik 'file reader'
 ```
 
-## Navigating your data
-In the napari GUI, you'll find the Blik widget at the bottom left. Select which volume to visualize with the dropdown menu.
-Alternatively, you can use `PageUp` and `PageDown` to switch to the previous or next volume.
+This widget allows additional arguments compared to the basic `napari` command line interface.
+
+---
 
-All napari functionality works as normal, and `dataset` will keep track of which layers come from where. Feel free to add any custom layer: blik won't interfere with them (if it does, please report it in an issue!).
+`name_regex` is a regex pattern used to extract tomogram names from file names (or things like the `rlnMicrographName` filed in `.star` files). By default, `blik` recognizes files containing `TS_\d+`, and will fall back to `\d+`. For example, the following files will be assigned `TS_001`:
 
-```{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!
 ```
+CoolSample2_TS_001_something.mrc
+Particles_TS_001.star
+```
+
+These, instead, will be assigned just `02`:
 
-To get a closer look at all the `DataBlocks` contained in your `DataSet`, try some of the following:
-```python
-dataset
-dataset.volumes
-dataset.particles
 ```
+image_02.em
+particles_02.tbl
+```
+
+---
+
+`names` can be used to whitelist specific tomogram names to load. If passed, anything *not* in the list won't be loaded.
+
+### Filters
+
+Simple imaging filters (such as gaussian) to help with visualization.
diff --git a/docs/tips_and_tricks.md b/docs/tips_and_tricks.md
deleted file mode 100644
index 9a566cda..00000000
--- a/docs/tips_and_tricks.md
+++ /dev/null
@@ -1,39 +0,0 @@
-# Tips and Tricks
-
-## File names and volumes
-To load your data into a `DataSet`, blik needs to know how to divide it in "volumes". By default, blik 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, blik 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
-blik.read(YOUR_DATA, name_regex='\d_\w+')
-```
-
-## DispatchList
-To navigate nested attributes in `DataSet`, you can use the convenient `DispatchList`. Some methods of `DataSet` return this type of list, which is printed as `*[elements]*`. Try:
-```python
-dataset.datablocks.name
-# *[name1, name2, name3, ...]*
-```
-
-It works for method calls and setting values...
-```python
-dataset.particles.pixel_size = 10
-dataset.particles.
-```
-
-... and you can even dispatch getitem calls with `.disp`, similarly to pandas' `.loc`:
-```python
-dataset.datablocks.disp[:3]  # returns a view of the first 3 elements (if possible) of each datablock
-```
diff --git a/setup.cfg b/setup.cfg
index d593ab29..b64c88dd 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,5 +1,7 @@
 [metadata]
 name = blik
+author = Blik Team
+author_email = blik-team@googlegroups.com
 url = https://github.com/gutsche-lab/blik
 download_url = https://github.com/gutsche-lab/blik
 license = GNU General Public License v3 (GPLv3)
@@ -7,8 +9,6 @@ license_file = LICENSE
 description = Python tool for visualising and interacting with cryo-ET and subtomogram averaging data.
 long_description = file: README.md, CHANGELOG.md
 long_description_content_type = text/markdown
-author = Blik Team
-author_email = blik-team@googlegroups.com
 classifiers =
     Development Status :: 2 - Pre-Alpha
     Environment :: X11 Applications :: Qt
@@ -46,22 +46,21 @@ include_package_data = True
 setup_requires =
     setuptools_scm
 install_requires =
-    napari>=0.4.12
     numpy
     dask
     pandas
     scipy
-    pyqtgraph
-    click
-    dynamotable==0.2.4
-    eulerangles==1.0.1
-    mrcfile==1.3.0
-    starfile==0.4.5
-    emfile==0.3
+    naaf>=0.2.2
+    magicgui>=0.4.0
 
 
 [options.extras_require]
+all =
+    napari[all]>=0.4.14
+    napari-properties-plotter
+    napari-properties-viewer
 testing =
+    napari[all]>=0.4.14
     tox
     pytest
     pytest-qt
@@ -75,12 +74,13 @@ dev =
 
 
 [options.entry_points]
-console_scripts =
-    blik = blik.__main__:cli
-
+napari.manifest = 
+    blik = blik:napari.yaml
 
 [options.package_data]
 * = *.txt, *.md
+blik =
+    blik:napari.yaml
 
 [bdist_wheel]
 universal = 1


Test patch:
diff --git a/.github/workflows/test_and_deploy.yml b/.github/workflows/test_and_deploy.yml
index 5fa12fd5..06c9b250 100644
--- a/.github/workflows/test_and_deploy.yml
+++ b/.github/workflows/test_and_deploy.yml
@@ -33,7 +33,9 @@ jobs:
           python -m pip install --upgrade pip
           pip install setuptools tox tox-gh-actions
       - name: Test with tox
-        run: tox
+        uses: GabrielBB/xvfb-action@v1
+        with:
+          run: python -m tox
         env:
           PLATFORM: ${{ matrix.platform }}
       - name: Coverage
diff --git a/blik/_tests/conftest.py b/blik/_tests/conftest.py
new file mode 100644
index 00000000..c5f061dc
--- /dev/null
+++ b/blik/_tests/conftest.py
@@ -0,0 +1,35 @@
+import pytest
+import numpy as np
+import pandas as pd
+import starfile
+import mrcfile
+
+
+@pytest.fixture(scope="session")
+def star_file(tmp_path_factory):
+    df = pd.DataFrame({
+        'rlnCoordinateX': [1, 1],
+        'rlnCoordinateY': [2, 2],
+        'rlnCoordinateZ': [3, 3],
+        'rlnOriginX': [0.1, 0.1],
+        'rlnOriginY': [0.2, 0.2],
+        'rlnOriginZ': [0.3, 0.3],
+        'rlnAngleRot': [0, 0],
+        'rlnAngleTilt': [0, 90],
+        'rlnAnglePsi': [90, 0],
+        'rlnMicrographName': ['a', 'b'],
+        'feature': ['x', 'y'],
+    })
+    file_path = tmp_path_factory.mktemp("data") / "test.star"
+    starfile.write(df, file_path)
+    return file_path
+
+
+@pytest.fixture(scope="session")
+def mrc_file(tmp_path_factory):
+    file_path = tmp_path_factory.mktemp("data") / "test.mrc"
+    data = np.zeros((10, 10, 10), np.float32)
+    data[5, 5, 5] = 1
+    with mrcfile.new(file_path, data) as mrc:
+        mrc.set_data(data)
+    return file_path
diff --git a/blik/_tests/test_cli.py b/blik/_tests/test_cli.py
deleted file mode 100644
index d2cbac97..00000000
--- a/blik/_tests/test_cli.py
+++ /dev/null
@@ -1,9 +0,0 @@
-from click.testing import CliRunner
-
-from blik.__main__ import cli
-
-runner = CliRunner()
-
-
-def test_cli():
-    runner.invoke(cli, 'test.mrc test.star -m bunch -n reg --no-show'.split())
diff --git a/blik/_tests/test_dataset.py b/blik/_tests/test_dataset.py
deleted file mode 100644
index cba1420a..00000000
--- a/blik/_tests/test_dataset.py
+++ /dev/null
@@ -1,46 +0,0 @@
-import pytest
-import numpy as np
-
-from blik.dataset import DataSet
-from blik.datablocks import PointBlock, ImageBlock
-
-
-pb = PointBlock(data=(), volume='test')
-ib = ImageBlock(data=np.random.rand(10, 10, 10), volume='test')
-pb_novolume = PointBlock(data=(), volume=None)
-pb_omni = PointBlock(data=(), volume='BLIK_OMNI')
-dataset = DataSet([pb, ib, pb_novolume, pb_omni])
-
-
-def test_dataset():
-    assert len(dataset) == 4
-    assert len(dataset.datablocks) == 4
-    assert len(dataset.volumes) == 2
-    assert len(dataset.omni) == 1
-    assert len(dataset.images) == 1
-
-
-def test_sanitize_dataset():
-    with pytest.raises(TypeError):
-        dataset.append('wrong')
-
-
-def test_view():
-    view = dataset[:1]
-    assert len(view) == 1
-    assert view._view_of is dataset
-    with pytest.raises(TypeError):
-        view.append(pb)
-
-
-def test_pprint():
-    brep = dataset.__pretty_repr__('base')
-    frep = dataset.__pretty_repr__('full')
-    assert len(brep.split('\n')) == 1
-    assert len(frep.split('\n')) == 8
-
-
-def test_depict():
-    dataset.datablocks.init_depictor()
-    dataset.depictors.depict()
-    assert len(dataset.napari_layers) == 4
diff --git a/blik/_tests/test_reader.py b/blik/_tests/test_reader.py
new file mode 100644
index 00000000..aa680433
--- /dev/null
+++ b/blik/_tests/test_reader.py
@@ -0,0 +1,14 @@
+from blik.reader import get_reader
+
+
+def test_reader(star_file):
+    reader = get_reader(star_file)
+    assert callable(reader)
+
+    layer_data_list = reader(star_file)
+    assert len(layer_data_list) == 8
+
+    pts = layer_data_list[0]
+    assert pts[2] == 'points'
+    vec = layer_data_list[2]
+    assert vec[2] == 'vectors'
diff --git a/blik/_tests/test_widget.py b/blik/_tests/test_widget.py
new file mode 100644
index 00000000..5645b65f
--- /dev/null
+++ b/blik/_tests/test_widget.py
@@ -0,0 +1,52 @@
+import napari
+import numpy as np
+
+from blik.widgets.volume_selector import volume_selector
+from blik.widgets.file_reader import file_reader
+from blik.widgets.filter import bandpass_filter, gaussian_filter
+
+
+def test_volume_widget(make_napari_viewer):
+    viewer = make_napari_viewer()
+
+    with napari.layers._source.layer_source(reader_plugin='blik'):
+        viewer.add_points(metadata={'blik_volume': 'test'})
+
+    wdg = volume_selector()
+    assert 'test' not in wdg.volume.choices
+    viewer.window.add_dock_widget(wdg)
+    assert 'test' in wdg.volume.choices
+
+
+def test_reader_widget(make_napari_viewer, star_file):
+    viewer = make_napari_viewer()
+    wdg = file_reader()
+    viewer.window.add_dock_widget(wdg)
+    wdg.files.value = [star_file]
+    wdg.name_regex.value = [r'\w']
+
+    assert len(viewer.layers) == 0
+    wdg()
+    assert len(viewer.layers) == 8
+
+
+def test_gaussian_filter_widget(make_napari_viewer, mrc_file):
+    viewer = make_napari_viewer()
+    wdg = gaussian_filter()
+    viewer.window.add_dock_widget(wdg)
+    layer = viewer.open(mrc_file)[0]
+    assert wdg.image.value == layer
+    wdg()
+    result = viewer.layers[-1].data
+    assert np.all(result != 1)
+
+
+def test_bandpass_filter_widget(make_napari_viewer, mrc_file):
+    viewer = make_napari_viewer()
+    wdg = bandpass_filter()
+    viewer.window.add_dock_widget(wdg)
+    layer = viewer.open(mrc_file)[0]
+    assert wdg.image.value == layer
+    wdg()
+    result = viewer.layers[-1].data
+    assert np.all(result != 1)
diff --git a/blik/datablocks/abstractblocks/_tests/test_datablock.py b/blik/datablocks/abstractblocks/_tests/test_datablock.py
deleted file mode 100644
index 3aad231a..00000000
--- a/blik/datablocks/abstractblocks/_tests/test_datablock.py
+++ /dev/null
@@ -1,10 +0,0 @@
-from pytest import raises
-
-from blik.datablocks.abstractblocks.datablock import DataBlock
-
-
-def test_datablock():
-    db = DataBlock()
-
-    with raises(ValueError):
-        db.init_depictor()
diff --git a/blik/datablocks/abstractblocks/_tests/test_multiblock.py b/blik/datablocks/abstractblocks/_tests/test_multiblock.py
deleted file mode 100644
index 88f54a94..00000000
--- a/blik/datablocks/abstractblocks/_tests/test_multiblock.py
+++ /dev/null
@@ -1,6 +0,0 @@
-from blik.datablocks.abstractblocks.multiblock import MultiBlock
-
-
-def test_multiblock():
-    block = MultiBlock()
-    assert isinstance(block, MultiBlock)
diff --git a/blik/datablocks/abstractblocks/_tests/test_simpleblock.py b/blik/datablocks/abstractblocks/_tests/test_simpleblock.py
deleted file mode 100644
index 2eadc753..00000000
--- a/blik/datablocks/abstractblocks/_tests/test_simpleblock.py
+++ /dev/null
@@ -1,17 +0,0 @@
-import pytest
-
-from blik.datablocks.abstractblocks.simpleblock import SimpleBlock
-
-
-def test_simpleblock():
-    # assert that SimpleBlock class cannot be instantiated directly
-    with pytest.raises(TypeError):
-        SimpleBlock([])
-
-    # assert that subclassing and implementing _data_setter works
-    class SubBlock(SimpleBlock):
-        def _data_setter(self, data):
-            return data
-
-    subblock = SubBlock(data=())
-    assert isinstance(subblock, (SimpleBlock, SubBlock))
diff --git a/blik/datablocks/multiblocks/_tests/__init__.py b/blik/datablocks/multiblocks/_tests/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/blik/datablocks/multiblocks/_tests/test_dipoleblock.py b/blik/datablocks/multiblocks/_tests/test_dipoleblock.py
deleted file mode 100644
index 30c08904..00000000
--- a/blik/datablocks/multiblocks/_tests/test_dipoleblock.py
+++ /dev/null
@@ -1,49 +0,0 @@
-import numpy as np
-from numpy.testing import assert_array_equal, assert_array_almost_equal
-
-from blik.datablocks.multiblocks.dipoleblock import DipoleBlock
-
-test_centers = np.asarray([[0, 0, 0],
-                           [1, 1, 1],
-                           [2, 2, 2]])
-
-test_vectors = np.asarray([[0, 0, 1], [0, 0, 2], [3, 3, 3]])
-
-test_vector_norms = np.expand_dims(np.linalg.norm(test_vectors, axis=1), axis=-1)
-normalised_test_vectors = test_vectors / test_vector_norms
-
-test_endpoints = test_centers + test_vectors
-
-
-def test_dipoleblock_instantiation():
-    block = DipoleBlock(startpoints_data=test_centers, endpoints_data=test_endpoints)
-    assert isinstance(block, DipoleBlock)
-
-
-def test_dipoleblock_orientation_vectors():
-    block = DipoleBlock(startpoints_data=test_centers, endpoints_data=test_endpoints)
-    assert_array_equal(block.orientation_vectors, test_vectors)
-
-
-def test_dipoleblock_normalised_orientation_vector():
-    block = DipoleBlock(startpoints_data=test_centers, endpoints_data=test_endpoints)
-    assert_array_almost_equal(block.normalised_orientation_vectors, normalised_test_vectors)
-
-
-def test_dipoleblock_rotation_matrices():
-    block = DipoleBlock(startpoints_data=test_centers, endpoints_data=test_endpoints)
-
-    # calculate rotation matrices to align test vector to each dipole in block
-    vector_to_align = np.array([0, 2, 1]).reshape(1, 3, 1)
-    rotation_matrices = block.calculate_orientation_block(vector_to_align)
-
-    # calculate vector to aligns new position after rotation by rotation_matrix
-    aligned_vectors = rotation_matrices @ vector_to_align
-    aligned_vectors = aligned_vectors.reshape(-1, 3)
-
-    # calculate normalised aligned vectors for comparison with dipoleblock vectors
-    aligned_vectors_norm = np.linalg.norm(aligned_vectors, axis=1)
-    normalised_aligned_vectors = aligned_vectors / np.expand_dims(aligned_vectors_norm, axis=-1)
-
-    # check normalised aligned vectors match normalised dipoleblock vectors
-    assert_array_almost_equal(normalised_aligned_vectors, block.normalised_orientation_vectors)
diff --git a/blik/datablocks/multiblocks/_tests/test_meshblock.py b/blik/datablocks/multiblocks/_tests/test_meshblock.py
deleted file mode 100644
index 52936289..00000000
--- a/blik/datablocks/multiblocks/_tests/test_meshblock.py
+++ /dev/null
@@ -1,40 +0,0 @@
-import numpy as np
-
-from blik.datablocks.multiblocks.meshblock import MeshBlock
-
-vertices = np.array([[0, 0, 0],
-                     [0, 0, 1],
-                     [0, 1, 0],
-                     [1, 0, 0],
-                     [0, 1, 1],
-                     [1, 0, 1],
-                     [1, 1, 0],
-                     [1, 1, 1]])
-
-faces = np.array([[0, 3, 4],
-                  [7, 3, 4],
-                  [0, 1, 4],
-                  [0, 5, 7],
-                  [3, 6, 5]
-                  ])
-
-
-def test_meshblock_instantiation():
-    # check that meshblock can be instantiated properly
-    block = MeshBlock(vertices_data=vertices, faces_data=faces)
-    assert isinstance(block, MeshBlock)
-    assert hasattr(block, 'vertices')
-    assert hasattr(block, 'faces')
-
-
-def test_triangles():
-    block = MeshBlock(vertices_data=vertices, faces_data=faces)
-    triangles = block.triangles
-    assert len(triangles) == len(faces)
-    assert triangles.shape[-1] == 3
-
-
-def test_midpoints():
-    block = MeshBlock(vertices_data=vertices, faces_data=faces)
-    midpoints = block.midpoints
-    assert midpoints.shape == (faces.shape[0], 3)
diff --git a/blik/datablocks/multiblocks/_tests/test_particleblock.py b/blik/datablocks/multiblocks/_tests/test_particleblock.py
deleted file mode 100644
index 37493f7f..00000000
--- a/blik/datablocks/multiblocks/_tests/test_particleblock.py
+++ /dev/null
@@ -1,8 +0,0 @@
-import numpy as np
-
-from blik.datablocks.multiblocks.particleblock import ParticleBlock
-
-
-def test_particleblock_instantiation():
-    block = ParticleBlock(positions_data=np.zeros((5, 3)), orientations_data=np.zeros((5, 3, 3)), properties_data={})
-    assert isinstance(block, ParticleBlock)
diff --git a/blik/datablocks/multiblocks/_tests/test_sphereblock.py b/blik/datablocks/multiblocks/_tests/test_sphereblock.py
deleted file mode 100644
index bdf0aa18..00000000
--- a/blik/datablocks/multiblocks/_tests/test_sphereblock.py
+++ /dev/null
@@ -1,10 +0,0 @@
-import numpy as np
-
-from blik.datablocks.multiblocks.sphereblock import SphereBlock
-
-
-def test_sphereblock():
-    block = SphereBlock(centers_data=np.zeros((2, 3)), radii_data=np.zeros(2))
-    assert isinstance(block, SphereBlock)
-    block.set_radii_from_edge_points(np.array([[1, 0, 0]]))
-    assert np.all(block.radii.data == 1)
diff --git a/blik/datablocks/simpleblocks/_tests/__init__.py b/blik/datablocks/simpleblocks/_tests/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/blik/datablocks/simpleblocks/_tests/test_imageblock.py b/blik/datablocks/simpleblocks/_tests/test_imageblock.py
deleted file mode 100644
index ba56865a..00000000
--- a/blik/datablocks/simpleblocks/_tests/test_imageblock.py
+++ /dev/null
@@ -1,16 +0,0 @@
-import numpy as np
-from blik.datablocks.simpleblocks.imageblock import ImageBlock
-from blik.depictors import ImageDepictor
-
-
-def test_imageblock_instantiation():
-    im = np.random.normal(0, 1, (28, 28, 28))
-    block = ImageBlock(data=im)
-    assert isinstance(block, ImageBlock)
-
-
-def test_depiction():
-    ib = ImageBlock(data=np.ones((10, 10, 10)))
-    ib.init_depictor()
-    assert isinstance(ib.depictors[0], ImageDepictor)
-    ib.update()
diff --git a/blik/datablocks/simpleblocks/_tests/test_lineblock.py b/blik/datablocks/simpleblocks/_tests/test_lineblock.py
deleted file mode 100644
index b9ffaa30..00000000
--- a/blik/datablocks/simpleblocks/_tests/test_lineblock.py
+++ /dev/null
@@ -1,35 +0,0 @@
-import numpy as np
-
-from blik.datablocks.simpleblocks.lineblock import LineBlock
-
-# test data for lineblock
-v = np.linspace(0, 12)
-line_2d = np.column_stack([v, np.sin(v)])
-line_3d = np.column_stack([v, np.sin(v), np.cos(v)])
-
-
-def test_lineblock_instantiation():
-    # test LineBlock instantiation
-    LineBlock(data=line_2d)
-    LineBlock(data=line_3d)
-
-
-def test_lineblock_fit_spline():
-    # test LineBlock.fit_spline
-    block = LineBlock(data=line_3d)
-    tck = block.fit_spline('xyz')
-
-    assert block._tck is not None
-    assert isinstance(tck, list)
-
-
-def test_lineblock_evaluate_spline():
-    # test LineBlock.evaluate_spline
-    block = LineBlock(data=line_3d)
-    block.fit_spline('xyz')
-    for n in [10, 100, 1000]:
-        spline = block.evaluate_spline(n)
-        assert isinstance(spline, np.ndarray)
-
-    assert block._tck is not None
-    assert isinstance(block._tck, list)
diff --git a/blik/datablocks/simpleblocks/_tests/test_orientationblock.py b/blik/datablocks/simpleblocks/_tests/test_orientationblock.py
deleted file mode 100644
index b569108a..00000000
--- a/blik/datablocks/simpleblocks/_tests/test_orientationblock.py
+++ /dev/null
@@ -1,18 +0,0 @@
-import numpy as np
-from numpy.testing import assert_array_equal
-from eulerangles import euler2matrix
-
-from blik.datablocks.simpleblocks.orientationblock import OrientationBlock
-
-# test data for orientationblock
-# orientations
-rot = np.zeros(50)
-tilt = np.linspace(0, 180, 50)
-psi = rot
-eulers = np.column_stack([rot, tilt, psi])
-rotation_matrices = euler2matrix(eulers, axes='zyz', intrinsic=True, right_handed_rotation=True)
-
-
-def test_orientationblock_instantiation():
-    block = OrientationBlock(data=rotation_matrices)
-    assert isinstance(block, OrientationBlock)
diff --git a/blik/datablocks/simpleblocks/_tests/test_pointblock.py b/blik/datablocks/simpleblocks/_tests/test_pointblock.py
deleted file mode 100644
index a2a89f37..00000000
--- a/blik/datablocks/simpleblocks/_tests/test_pointblock.py
+++ /dev/null
@@ -1,61 +0,0 @@
-import pytest
-import numpy as np
-from numpy.testing import assert_array_equal
-
-from blik.datablocks.simpleblocks.pointblock import PointBlock
-
-# test data pointblock
-points_2d = [[1, 2], [3, 4]]
-
-points_3d = [[1, 2, 3], [4, 5, 6]]
-
-point_nd = np.arange(48).reshape(6, 8)
-
-
-def test_pointblock_instantiation():
-    # test instantiation for 2d, 3d and nd points
-    PointBlock(data=points_2d)
-    PointBlock(data=points_3d)
-
-
-def test_pointblock_xyz():
-    # test 'x', 'y' and 'z' properties
-    block = PointBlock(data=points_2d)
-    assert_array_equal(block.x, [[1], [3]])
-    assert_array_equal(block.y, [[2], [4]])
-    assert_array_equal(block.z, [[0], [0]])
-
-    block = PointBlock(data=points_3d)
-    assert_array_equal(block.x, [[1], [4]])
-    assert_array_equal(block.y, [[2], [5]])
-    assert_array_equal(block.z, [[3], [6]])
-
-
-def test_pointblock_get_named_dimensions():
-    # test _get_named_dimensions method
-    block = PointBlock(data=points_3d)
-
-    x = block._get_named_dimensions('x')
-    assert isinstance(x, np.ndarray)
-
-    # try with multiple named dims
-    xyz = block._get_named_dimensions('xyz')
-    assert isinstance(xyz, np.ndarray)
-    assert xyz.shape == (2, 3)
-
-
-def test_pointblock_center_of_mass():
-    # multi point case
-    block = PointBlock(data=points_3d)
-    assert_array_equal(block.center_of_mass, [2.5, 3.5, 4.5])
-
-
-def test_pointblock_distance_to():
-    # test distance_to method
-    block = PointBlock(data=points_2d)
-
-    assert block.distance_to([2, 3, 0]) == 0
-
-    # check for failure
-    with pytest.raises(ValueError):
-        block.distance_to([1, 2, 3, 4, 5, 6])
diff --git a/blik/datablocks/simpleblocks/_tests/test_propertyblock.py b/blik/datablocks/simpleblocks/_tests/test_propertyblock.py
deleted file mode 100644
index e26bff79..00000000
--- a/blik/datablocks/simpleblocks/_tests/test_propertyblock.py
+++ /dev/null
@@ -1,6 +0,0 @@
-from blik.datablocks.simpleblocks.propertyblock import PropertyBlock
-
-
-def test_propertyblock_instantiation():
-    block = PropertyBlock(data={})
-    assert isinstance(block, PropertyBlock)
diff --git a/blik/datablocks/simpleblocks/_tests/test_sphereblock.py b/blik/datablocks/simpleblocks/_tests/test_sphereblock.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/blik/depictors/napari/_tests/__init__.py b/blik/depictors/napari/_tests/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/blik/depictors/napari/_tests/test_images.py b/blik/depictors/napari/_tests/test_images.py
deleted file mode 100644
index 7cd849ae..00000000
--- a/blik/depictors/napari/_tests/test_images.py
+++ /dev/null
@@ -1,15 +0,0 @@
-import numpy as np
-from napari.layers import Image
-
-from blik.datablocks import ImageBlock
-from blik.depictors import ImageDepictor
-
-
-def test_image_depictor():
-    imageblock = ImageBlock(data=np.ones((10, 10, 10)))
-    image_depictor = ImageDepictor(imageblock)
-    assert image_depictor.datablock is imageblock
-    assert len(image_depictor.layers) == 0
-    image_depictor.depict()
-    assert len(image_depictor.layers) == 1
-    assert isinstance(image_depictor.layers[0], Image)
diff --git a/blik/depictors/napari/_tests/test_lines.py b/blik/depictors/napari/_tests/test_lines.py
deleted file mode 100644
index acac25e7..00000000
--- a/blik/depictors/napari/_tests/test_lines.py
+++ /dev/null
@@ -1,17 +0,0 @@
-import numpy as np
-from napari.layers import Shapes, Points
-
-from blik.datablocks import LineBlock
-from blik.depictors import LineDepictor
-
-
-def test_line_depictor():
-    lineblock = LineBlock(data=np.random.rand(5, 3))
-    line_depictor = LineDepictor(lineblock)
-    assert line_depictor.datablock is lineblock
-    assert len(line_depictor.layers) == 0
-    line_depictor.depict()
-    assert len(line_depictor.layers) == 2
-    assert isinstance(line_depictor.points, Points)
-    assert isinstance(line_depictor.backbone, Shapes)
-    line_depictor.update()
diff --git a/blik/depictors/napari/_tests/test_particles.py b/blik/depictors/napari/_tests/test_particles.py
deleted file mode 100644
index 2b3beb61..00000000
--- a/blik/depictors/napari/_tests/test_particles.py
+++ /dev/null
@@ -1,20 +0,0 @@
-import numpy as np
-from napari.layers import Vectors, Points
-
-from blik.datablocks import ParticleBlock
-from blik.depictors import ParticleDepictor
-
-
-def test_particle_depictor():
-    particleblock = ParticleBlock(positions_data=np.ones((2, 3)),
-                                  orientations_data=np.ones((2, 3, 3)),
-                                  properties_data={'a': [1, 2]})
-    particle_depictor = ParticleDepictor(particleblock)
-    assert particle_depictor.datablock is particleblock
-    assert len(particle_depictor.layers) == 0
-    particle_depictor.depict()
-    assert len(particle_depictor.layers) == 4
-    assert isinstance(particle_depictor.points, Points)
-    assert all(isinstance(vec, Vectors) for vec in particle_depictor.vectors)
-    particle_depictor.update()
-    particle_depictor.color_by_categorical_property('a')
diff --git a/blik/depictors/napari/_tests/test_points.py b/blik/depictors/napari/_tests/test_points.py
deleted file mode 100644
index 32517fcf..00000000
--- a/blik/depictors/napari/_tests/test_points.py
+++ /dev/null
@@ -1,15 +0,0 @@
-import numpy as np
-from napari.layers import Points
-
-from blik.datablocks import PointBlock
-from blik.depictors import PointDepictor
-
-
-def test_points_depictor():
-    pointblock = PointBlock(data=np.ones((2, 3)))
-    point_depictor = PointDepictor(pointblock)
-    assert point_depictor.datablock is pointblock
-    assert len(point_depictor.layers) == 0
-    point_depictor.depict()
-    assert len(point_depictor.layers) == 1
-    assert isinstance(point_depictor.layers[0], Points)
diff --git a/blik/gui/_tests/__init__.py b/blik/gui/_tests/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/blik/io_/reading/_tests/__init__.py b/blik/io_/reading/_tests/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/blik/io_/reading/_tests/test_read.py b/blik/io_/reading/_tests/test_read.py
deleted file mode 100644
index 130cf778..00000000
--- a/blik/io_/reading/_tests/test_read.py
+++ /dev/null
@@ -1,52 +0,0 @@
-import numpy as np
-import pandas as pd
-import mrcfile
-import starfile
-
-from blik.io_.reading.main import read
-from blik.dataset import DataSet
-from blik.datablocks import PointBlock
-
-
-def test_read_path(tmp_path):
-    # mrc file
-    mrc_path1 = tmp_path / 'test1.mrc'
-    mrc_path2 = tmp_path / 'test2.mrc'
-    mrcfile.new(str(mrc_path1), np.ones((3, 3), dtype=np.float32))
-    mrcfile.new(str(mrc_path2), np.ones((3, 3), dtype=np.float32))
-
-    # star file
-    df = pd.DataFrame({
-        'rlnCoordinateX': [1, 2],
-        'rlnCoordinateY': [1, 2],
-        'rlnCoordinateZ': [1, 2],
-        'rlnOriginX': [1, 2],
-        'rlnOriginY': [1, 2],
-        'rlnOriginZ': [1, 2],
-        'rlnAngleRot': [1, 2],
-        'rlnAngleTilt': [1, 2],
-        'rlnAnglePsi': [1, 2],
-        'rlnMicrographName': ['test1', 'test2'],
-    })
-    star_path = tmp_path / 'test.star'
-    starfile.new(df, star_path)
-
-    dataset = read(tmp_path / '*', name_regex=r'test\d')
-
-    assert len(dataset) == 4
-    assert len(dataset.volumes) == 2
-
-
-def test_read_dataset():
-    dataset = read(DataSet())
-    assert isinstance(dataset, DataSet)
-    assert len(dataset) == 0
-
-
-def test_read_datablock():
-    dataset = read(PointBlock(data=()))
-    assert isinstance(dataset, DataSet)
-    assert len(dataset) == 1
-    dataset = read([PointBlock(data=()), PointBlock(data=())])
-    assert isinstance(dataset, DataSet)
-    assert len(dataset) == 2
diff --git a/blik/io_/reading/_tests/test_read_em.py b/blik/io_/reading/_tests/test_read_em.py
deleted file mode 100644
index b3d5e8f5..00000000
--- a/blik/io_/reading/_tests/test_read_em.py
+++ /dev/null
@@ -1,13 +0,0 @@
-import numpy as np
-import emfile
-
-from blik.io_.reading.em import read_em
-from blik.datablocks import ImageBlock
-
-
-def test_read_em(tmp_path):
-    file_path = tmp_path / 'test.em'
-    emfile.write(str(file_path), np.ones((3, 3, 3)))
-    imageblock = read_em(file_path)
-    assert isinstance(imageblock, ImageBlock)
-    assert imageblock.data.shape == (3, 3, 3)
diff --git a/blik/io_/reading/_tests/test_read_mrc.py b/blik/io_/reading/_tests/test_read_mrc.py
deleted file mode 100644
index 8e89762d..00000000
--- a/blik/io_/reading/_tests/test_read_mrc.py
+++ /dev/null
@@ -1,13 +0,0 @@
-import numpy as np
-import mrcfile
-
-from blik.io_.reading.mrc import read_mrc
-from blik.datablocks import ImageBlock
-
-
-def test_read_mrc(tmp_path):
-    file_path = tmp_path / 'test.mrc'
-    mrcfile.new(str(file_path), np.ones((3, 3, 3), dtype=np.float32))
-    imageblock = read_mrc(file_path)
-    assert isinstance(imageblock, ImageBlock)
-    assert imageblock.data.shape == (3, 3, 3)
diff --git a/blik/io_/reading/_tests/test_read_star.py b/blik/io_/reading/_tests/test_read_star.py
deleted file mode 100644
index baa636ae..00000000
--- a/blik/io_/reading/_tests/test_read_star.py
+++ /dev/null
@@ -1,52 +0,0 @@
-import pandas as pd
-import starfile
-
-from blik.io_.reading.star import read_star
-from blik.datablocks import ParticleBlock
-
-
-def test_read_relion30_3d(tmp_path):
-    df = pd.DataFrame({
-        'rlnCoordinateX': [1, 2],
-        'rlnCoordinateY': [1, 2],
-        'rlnCoordinateZ': [1, 2],
-        'rlnOriginX': [1, 2],
-        'rlnOriginY': [1, 2],
-        'rlnOriginZ': [1, 2],
-        'rlnAngleRot': [1, 2],
-        'rlnAngleTilt': [1, 2],
-        'rlnAnglePsi': [1, 2],
-        'rlnMicrographName': ['a', 'b'],
-    })
-    file_path = tmp_path / 'test.star'
-    starfile.new(df, file_path)
-
-    particleblocks = read_star(file_path, name_regex=r'\w')
-    assert all(isinstance(pb, ParticleBlock) for pb in particleblocks)
-    assert particleblocks[0].name == 'a'
-
-
-def test_read_relion31_3d(tmp_path):
-    df_optics = pd.DataFrame({
-        'rlnOpticsGroup': [1],
-        'rlnImagePixelSize': [1],
-    })
-    df_particles = pd.DataFrame({
-        'rlnCoordinateX': [1, 2],
-        'rlnCoordinateY': [1, 2],
-        'rlnCoordinateZ': [1, 2],
-        'rlnOriginXAngst': [1, 2],
-        'rlnOriginYAngst': [1, 2],
-        'rlnOriginZAngst': [1, 2],
-        'rlnAngleRot': [1, 2],
-        'rlnAngleTilt': [1, 2],
-        'rlnAnglePsi': [1, 2],
-        'rlnMicrographName': ['a', 'b'],
-        'rlnOpticsGroup': [1, 1],
-    })
-    data_dict = {'optics': df_optics, 'particles': df_particles}
-    file_path = tmp_path / 'test.star'
-    starfile.new(data_dict, file_path)
-
-    particleblocks = read_star(file_path)
-    assert all(isinstance(pb, ParticleBlock) for pb in particleblocks)
diff --git a/blik/io_/reading/_tests/test_read_tbl.py b/blik/io_/reading/_tests/test_read_tbl.py
deleted file mode 100644
index b8f5d1a0..00000000
--- a/blik/io_/reading/_tests/test_read_tbl.py
+++ /dev/null
@@ -1,25 +0,0 @@
-import pandas as pd
-import dynamotable
-
-from blik.io_.reading.tbl import read_tbl
-from blik.datablocks import ParticleBlock
-
-
-def test_read_tbl(tmp_path):
-    df = pd.DataFrame({
-        'x': [1, 2],
-        'y': [1, 2],
-        'z': [1, 2],
-        'dx': [1, 2],
-        'dy': [1, 2],
-        'dz': [1, 2],
-        'tdrot': [1, 2],
-        'tilt': [1, 2],
-        'narot': [1, 2],
-        'tomo': [1, 2],
-    })
-    file_path = tmp_path / 'test.tbl'
-    dynamotable.write(df, file_path)
-
-    particleblocks = read_tbl(file_path)
-    assert all(isinstance(pb, ParticleBlock) for pb in particleblocks)
diff --git a/blik/io_/writing/_tests/__init__.py b/blik/io_/writing/_tests/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/blik/io_/writing/_tests/test_write_em.py b/blik/io_/writing/_tests/test_write_em.py
deleted file mode 100644
index c792eedf..00000000
--- a/blik/io_/writing/_tests/test_write_em.py
+++ /dev/null
@@ -1,10 +0,0 @@
-import numpy as np
-
-from blik.io_.writing.em import write_em
-from blik.datablocks import ImageBlock
-
-
-def test_write_em(tmp_path):
-    file_path = tmp_path / 'test.em'
-    imageblock = ImageBlock(data=np.ones((3, 3, 3)))
-    write_em(imageblock, str(file_path))
diff --git a/blik/io_/writing/_tests/test_write_mrc.py b/blik/io_/writing/_tests/test_write_mrc.py
deleted file mode 100644
index 77d66224..00000000
--- a/blik/io_/writing/_tests/test_write_mrc.py
+++ /dev/null
@@ -1,10 +0,0 @@
-import numpy as np
-
-from blik.io_.writing.mrc import write_mrc
-from blik.datablocks import ImageBlock
-
-
-def test_write_mrc(tmp_path):
-    file_path = tmp_path / 'test.mrc'
-    imageblock = ImageBlock(data=np.ones((3, 3, 3), dtype=np.float32))
-    write_mrc(imageblock, str(file_path))
diff --git a/blik/io_/writing/_tests/test_write_star.py b/blik/io_/writing/_tests/test_write_star.py
deleted file mode 100644
index e05f4db6..00000000
--- a/blik/io_/writing/_tests/test_write_star.py
+++ /dev/null
@@ -1,13 +0,0 @@
-import numpy as np
-
-from blik.io_.writing.star import write_star
-from blik.datablocks import ParticleBlock
-
-
-def test_write_star(tmp_path):
-    file_path = tmp_path / 'test.star'
-    particleblock = ParticleBlock(positions_data=np.ones((2, 3)),
-                                  orientations_data=np.ones((2, 3, 3)),
-                                  properties_data={'a': np.ones((2,))})
-
-    write_star(particleblock, file_path)
diff --git a/blik/utils/_tests/test_dispatch_list.py b/blik/utils/_tests/test_dispatch_list.py
deleted file mode 100644
index fe2b972c..00000000
--- a/blik/utils/_tests/test_dispatch_list.py
+++ /dev/null
@@ -1,20 +0,0 @@
-import pytest
-
-from blik.utils import DispatchList
-
-
-def test_dispatch_list():
-    lst = ['test']
-    dl = DispatchList(lst)
-    assert dl.upper()._data == ['TEST']
-    assert 'test' in dl
-    dl[0] = 'test2'
-    assert dl[0] == 'test2'
-    dl2 = DispatchList([dl])
-    assert dl2.flatten()[0] == 'test2'
-    assert dl.disp[0]._data == ['t']
-    with pytest.raises(AttributeError):
-        dl.nope
-    dl.append('test3')
-    assert 'test3' in dl
-    dl[-1:] == ['test3']