Repository: brisvag/blik (language: python)

Issue:
sphereblock is now new-style multiblock
Together with #98, addresses and closes #85.


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/make_docs.yml b/.github/workflows/make_docs.yml
new file mode 100644
index 00000000..c6f931f3
--- /dev/null
+++ b/.github/workflows/make_docs.yml
@@ -0,0 +1,35 @@
+name: Make docs
+
+on:
+  push:
+    branches:
+      - master
+    tags:
+      - 'v[0-9]+.[0-9]+.[0-9]+'  # accept all tags of the format vX.X.X
+
+jobs:
+  deploy:
+    name: Make docs
+    runs-on: ubuntu-latest
+    if: github.repository == 'gutsche-lab/peepingtom'
+    steps:
+    - name: Checkout code
+      uses: actions/checkout@master
+    - name: Install Python
+      uses: actions/setup-python@v2
+      with:
+        python-version: 3.8
+    - name: Install Dependencies
+      working-directory: ./docs
+      run: |
+        python -m pip install --upgrade pip
+        python -m pip install -r requirements.txt
+    - name: Build the book
+      working-directory: ./docs
+      run: |
+        jupyter-book build .
+    - name: GitHub Pages
+      uses: peaceiris/actions-gh-pages@v3
+      with:
+        github_token: ${{ secrets.GITHUB_TOKEN }}
+        publish_dir: ./docs/_build/html
diff --git a/.github/workflows/publish_pypi.yml b/.github/workflows/publish_pypi.yml
index a65d0dd7..c906789f 100644
--- a/.github/workflows/publish_pypi.yml
+++ b/.github/workflows/publish_pypi.yml
@@ -5,7 +5,7 @@ on:
     branches:
       - master
     tags:
-      - 'v*' # version tags (e.g: v0.0.1)
+      - 'v[0-9]+.[0-9]+.[0-9]+'  # accept all tags of the format vX.X.X
 
 jobs:
   deploy:
@@ -13,24 +13,21 @@ jobs:
     runs-on: ubuntu-latest
     if: github.repository == 'gutsche-lab/peepingtom'
     steps:
-    - uses: actions/checkout@master
-    - name: Install Python 3
+    - name: Checkout code
+      uses: actions/checkout@master
+    - name: Install Python
       uses: actions/setup-python@v2
       with:
         python-version: 3.8
-    - name: Install requirements
+    - name: Install Dependencies
       run: |
         python -m pip install --upgrade pip
-        pip install pytest
-        pip install -r requirements.txt
-    - name: Run tests
-      run: pytest
-    - name: Build and publish
-      if: ${{ success() }}
-      env:
-        TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }}
-        TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
+        python -m pip install pep517
+    - name: Build Distribution
       run: |
-        pip install setuptools wheel twine
-        python setup.py sdist bdist_wheel
-        twine upload dist/*
+        python -m pep517.build .
+    - name: Publish PyPI Package
+      uses: pypa/gh-action-pypi-publish@master
+      with:
+        user: ${{ secrets.PYPI_USERNAME }}
+        password: ${{ secrets.PYPI_PASSWORD }}
diff --git a/.gitignore b/.gitignore
index 2ab342c4..8debf5d0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,11 +1,143 @@
-**/.ipynb_checkpoints
-.pytest_cache
-.idea
-dist/
+# Byte-compiled / optimized / DLL files
 __pycache__/
-*.ipynb
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+share/python-wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# PyInstaller
+#  Usually these files are written by a python script from a template
+#  before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.nox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+*.py,cover
+.hypothesis/
+.pytest_cache/
+cover/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+db.sqlite3
+db.sqlite3-journal
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+.pybuilder/
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# IPython
+profile_default/
+ipython_config.py
+
+# pyenv
+#   For a library or package, you might want to ignore these files since the code is
+#   intended to run in multiple environments; otherwise, check them in:
+# .python-version
+
+# pipenv
+#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
+#   However, in case of collaboration, if having platform-specific dependencies or dependencies
+#   having no cross-platform support, pipenv may install dependencies that don't work, or not
+#   install all needed dependencies.
+#Pipfile.lock
+
+# PEP 582; used by e.g. github.com/David-OConnor/pyflow
+__pypackages__/
+
+# Celery stuff
+celerybeat-schedule
+celerybeat.pid
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+.dmypy.json
+dmypy.json
+
+# Pyre type checker
+.pyre/
+
+# pytype static type analyzer
+.pytype/
+
+# Cython debug symbols
+cython_debug/
+
+####################
 
-# test data
-*.mrc
-*.star
-*.tbl
+# setuptools_scm version
+peepingtom/_version.py
diff --git a/README.md b/README.md
index 4eae0df3..522dc82d 100644
--- a/README.md
+++ b/README.md
@@ -1,12 +1,14 @@
+![logo](docs/images/logo.png)
+
 # PeepingTom
 
 ![Codecov branch](https://img.shields.io/codecov/c/github/gutsche-lab/peepingtom/master?label=codecov)
 ![PyPI](https://img.shields.io/pypi/v/peepingtom)
 ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/peepingtom)
 
-**PeepingTom** is a python tool enable easy interaction with cryo-ET and subtomogram averaging data. It leverages the fast, multi-dimensional [napari viewer](https://github.com/napari/napari) and the scientific python stack.
+**PeepingTom** is a python tool for visualising and interacting with cryo-ET and subtomogram averaging data. It leverages the fast, multi-dimensional [napari viewer](https://napari.org) and the scientific python stack.
 
-**DISCLAIMER**: this package is in early development phase. Expect frequent bugs and crashes. Feel free to report them on the issue tracker.
+**DISCLAIMER**: this package is in early development phase. Expect frequent bugs and crashes. Please, report them on the issue tracker and ask if anything is unclear!
 
 ## Installation
 
@@ -16,16 +18,16 @@ pip install peepingtom
 
 ## Basic Usage
 
-To look at some data:
-```python
-import peepingtom as pt
-p = pt.peep('path_to_data.star')
-p = pt.peep('path_to.mrc')
-p = pt.peep('path_to_dir', recursive=True, filters='filename_regex')
+From the command line:
+```bash
+peep /path/to.star /path/to/mrc/files/
 ```
 
-## Tips
-To navigate nested attributes in `Peeper`, you can use the convenient dot notation:
+PeepingTom accepts any combination of files and directory, and will try find the right files and display them in napari. `peep` will also start an ipython shell with the following setup:
 ```python
-p.datablocks.depictor.layers.visible  # visible status of all the napari layers
+import peepingtom as pt
+peeper = Peeper([your_data])
+viewer = peeper.napari_viewer
 ```
+
+For more information, check out the help page with `peep -h` and [head over to the docs](https://gutsche-lab.github.io/peepingtom).
diff --git a/codecov.yml b/codecov.yml
new file mode 100644
index 00000000..b0641e99
--- /dev/null
+++ b/codecov.yml
@@ -0,0 +1,11 @@
+ignore:
+  - peepingtom/_version.py
+coverage:
+  status:
+    project:
+      default:
+        target: auto
+        threshold: 0.5%  # coverage can drop by up to 0.5% while still posting success
+    patch: off
+comment:
+  require_changes: true  # if true: only post the PR comment if coverage changes
diff --git a/docs/_config.yml b/docs/_config.yml
new file mode 100644
index 00000000..96a82d8b
--- /dev/null
+++ b/docs/_config.yml
@@ -0,0 +1,66 @@
+# Book settings
+# Learn more at https://jupyterbook.org/customize/config.html
+title: PeepingTom
+logo: images/logo.png
+author: PeepingTom Team
+email: peepingtom_team@googlegroups.com
+copyright: "2020" # Copyright year to be placed in the footer
+only_build_toc_files: true
+
+# Force re-execution of notebooks on each build.
+# See https://jupyterbook.org/content/execute.html
+execute:
+  execute_notebooks: force
+
+# Information about where the book exists on the web
+repository:
+  url: https://github.com/gutsche-lab/peepingtom # Online location of your book
+  path_to_book: docs # Optional path to your book, relative to the repository root
+  branch: master # Which branch of the repository should be used when creating links (optional)
+
+# Add GitHub buttons to your book
+# See https://jupyterbook.org/customize/config.html#add-a-link-to-your-repository
+html:
+  use_issues_button: true
+  use_repository_button: true
+
+sphinx:
+  extra_extensions:
+    - sphinx.ext.viewcode
+    - sphinx.ext.autodoc
+    - sphinx.ext.intersphinx
+
+  config:
+    autodoc_default_options:
+        special-members: False
+        undoc-members: True
+        show-inheritance: True
+    intersphinx_mapping:
+      python:
+        - "https://docs.python.org/3"
+        - null
+      napari:
+        - "https://napari.org/docs/dev/"
+        - null
+    html_favicon: "images/favicon.ico"
+    html_theme: "sphinx_material"
+    html_show_sourcelink: False
+    pygments_style: "default"
+    html_theme_options:
+      base_url: "https://gutsche-lab.github.io/peepingtom"  # Specify a base_url used to generate sitemap.xml links
+      repo_url: "https://github.com/gutsche-lab/peepingtom"
+      repo_name: "gutsche-lab/peepingtom"
+      html_minify: True
+      css_minify: True
+      repo_type: "github"
+      globaltoc_depth: 2
+      color_primary: deep-purple
+      color_accent: "orange"
+      theme_color: "4093d6"
+      master_doc: False
+    html_sidebars:
+      "**":
+        - globaltoc.html
+        - logo-text.html
+        - localtoc.html
+        - searchbox.html
diff --git a/docs/_toc.yml b/docs/_toc.yml
new file mode 100644
index 00000000..b66ce39a
--- /dev/null
+++ b/docs/_toc.yml
@@ -0,0 +1,6 @@
+# Table of content
+# Learn more at https://jupyterbook.org/customize/toc.html
+- file: index
+
+- file: getting_started
+- file: tips_and_tricks
diff --git a/docs/datamodel.gv b/docs/datamodel.gv
deleted file mode 100644
index 4c13aeaa..00000000
--- a/docs/datamodel.gv
+++ /dev/null
@@ -1,75 +0,0 @@
-digraph {
-    ranksep = 1
-    rankdir = LR
-    node[color = black]
-    subgraph cluster_crates {
-        color = firebrick
-        label = "Multi-Volume Dataset"
-        subgraph cluster_crate1 {
-            color = blue
-            label = "DataCrate1"
-            ParticleBlock1
-            PointBlock
-        }
-        subgraph cluster_crate2 {
-            color = blue
-            label = "DataCrate2"
-            ImageBlock
-            ParticleBlock2
-            LineBlock
-        }
-    }
-    subgraph cluster_peeper {
-        color = gold
-        label = "Peeper"
-        ParticleDepictor1
-        PointDepictor
-        ImageDepictor
-        ParticleDepictor2
-        LineDepictor
-    }
-    subgraph cluster_middle_layer {
-        style = invis
-        subgraph cluster_analysis {
-            color = cyan
-            style = vis
-            label = "Analysis"
-            classify_radial_distance
-        }
-        subgraph cluster_models {
-            color = violet
-            style = vis
-            label = "Models"
-            FilamentModel
-            SphereModel
-        }
-    }
-    napari [
-        color = green
-        label = "Napari Viewer"
-        ]
-
-    edge[color = cornflowerblue]
-    ParticleBlock1 -> ParticleDepictor1
-    PointBlock -> PointDepictor
-    ImageBlock -> ImageDepictor
-    ParticleBlock2 -> ParticleDepictor2
-    LineBlock -> LineDepictor
-    LineBlock -> FilamentModel
-    PointBlock -> SphereModel
-
-    edge[color = gold]
-    ParticleDepictor1 -> napari
-    PointDepictor -> napari
-    ImageDepictor -> napari
-    ParticleDepictor2 -> napari
-    LineDepictor -> napari
-
-    edge[color = cyan]
-    classify_radial_distance -> ParticleBlock1
-    classify_radial_distance -> ParticleBlock2
-
-    edge[color = violet]
-    FilamentModel -> ParticleBlock2
-    SphereModel -> ParticleBlock1
-    }
diff --git a/docs/datamodel.svg b/docs/datamodel.svg
deleted file mode 100644
index 1aad9ec2..00000000
--- a/docs/datamodel.svg
+++ /dev/null
@@ -1,220 +0,0 @@
-<?xml version="1.0" encoding="UTF-8" ?>
-<svg width="887pt" height="389pt" viewBox="0.00 0.00 887.04 389.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
-<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 385)">
-<title>%0</title>
-<polygon fill="#ffffff" stroke="transparent" points="-4,4 -4,-385 883.0392,-385 883.0392,4 -4,4"/>
-<g id="clust1" class="cluster">
-<title>cluster_crates</title>
-<polygon fill="none" stroke="#b22222" points="287.6254,-8 287.6254,-373 456.4789,-373 456.4789,-8 287.6254,-8"/>
-<text text-anchor="middle" x="372.0522" y="-356.4" font-family="Times,serif" font-size="14.00" fill="#000000">Multi-Volume Dataset</text>
-</g>
-<g id="clust2" class="cluster">
-<title>cluster_crate1</title>
-<polygon fill="none" stroke="#0000ff" points="295.6254,-209 295.6254,-340 448.4789,-340 448.4789,-209 295.6254,-209"/>
-<text text-anchor="middle" x="372.0522" y="-323.4" font-family="Times,serif" font-size="14.00" fill="#000000">DataCrate1</text>
-</g>
-<g id="clust3" class="cluster">
-<title>cluster_crate2</title>
-<polygon fill="none" stroke="#0000ff" points="295.6254,-16 295.6254,-201 448.4789,-201 448.4789,-16 295.6254,-16"/>
-<text text-anchor="middle" x="372.0522" y="-184.4" font-family="Times,serif" font-size="14.00" fill="#000000">DataCrate2</text>
-</g>
-<g id="clust4" class="cluster">
-<title>cluster_peeper</title>
-<polygon fill="none" stroke="#ffd700" points="504.4789,-16 504.4789,-309 678.7651,-309 678.7651,-16 504.4789,-16"/>
-<text text-anchor="middle" x="591.622" y="-292.4" font-family="Times,serif" font-size="14.00" fill="#000000">Peeper</text>
-</g>
-<g id="clust5" class="cluster">
-<title>cluster_middle_layer</title>
-</g>
-<g id="clust6" class="cluster">
-<title>cluster_analysis</title>
-<polygon fill="none" stroke="#00ffff" points="16,-236 16,-313 239.6254,-313 239.6254,-236 16,-236"/>
-<text text-anchor="middle" x="127.8127" y="-296.4" font-family="Times,serif" font-size="14.00" fill="#000000">Analysis</text>
-</g>
-<g id="clust7" class="cluster">
-<title>cluster_models</title>
-<polygon fill="none" stroke="#ee82ee" points="49.2315,-97 49.2315,-228 206.3939,-228 206.3939,-97 49.2315,-97"/>
-<text text-anchor="middle" x="127.8127" y="-211.4" font-family="Times,serif" font-size="14.00" fill="#000000">Models</text>
-</g>
-<!-- ParticleBlock1 -->
-<g id="node1" class="node">
-<title>ParticleBlock1</title>
-<ellipse fill="none" stroke="#000000" cx="372.0522" cy="-289" rx="68.3536" ry="18"/>
-<text text-anchor="middle" x="372.0522" y="-284.8" font-family="Times,serif" font-size="14.00" fill="#000000">ParticleBlock1</text>
-</g>
-<!-- ParticleDepictor1 -->
-<g id="node6" class="node">
-<title>ParticleDepictor1</title>
-<ellipse fill="none" stroke="#000000" cx="591.622" cy="-258" rx="79.2864" ry="18"/>
-<text text-anchor="middle" x="591.622" y="-253.8" font-family="Times,serif" font-size="14.00" fill="#000000">ParticleDepictor1</text>
-</g>
-<!-- ParticleBlock1&#45;&gt;ParticleDepictor1 -->
-<g id="edge1" class="edge">
-<title>ParticleBlock1-&gt;ParticleDepictor1</title>
-<path fill="none" stroke="#6495ed" d="M432.6318,-280.4471C457.8445,-276.8874 487.4228,-272.7114 514.185,-268.933"/>
-<polygon fill="#6495ed" stroke="#6495ed" points="514.8766,-272.3701 524.2891,-267.5064 513.8979,-265.4389 514.8766,-272.3701"/>
-</g>
-<!-- PointBlock -->
-<g id="node2" class="node">
-<title>PointBlock</title>
-<ellipse fill="none" stroke="#000000" cx="372.0522" cy="-235" rx="54.4932" ry="18"/>
-<text text-anchor="middle" x="372.0522" y="-230.8" font-family="Times,serif" font-size="14.00" fill="#000000">PointBlock</text>
-</g>
-<!-- PointDepictor -->
-<g id="node7" class="node">
-<title>PointDepictor</title>
-<ellipse fill="none" stroke="#000000" cx="591.622" cy="-204" rx="65.4266" ry="18"/>
-<text text-anchor="middle" x="591.622" y="-199.8" font-family="Times,serif" font-size="14.00" fill="#000000">PointDepictor</text>
-</g>
-<!-- PointBlock&#45;&gt;PointDepictor -->
-<g id="edge2" class="edge">
-<title>PointBlock-&gt;PointDepictor</title>
-<path fill="none" stroke="#6495ed" d="M422.4259,-227.888C452.3732,-223.6599 490.7607,-218.2401 523.3987,-213.6321"/>
-<polygon fill="#6495ed" stroke="#6495ed" points="523.915,-217.094 533.3274,-212.2303 522.9363,-210.1628 523.915,-217.094"/>
-</g>
-<!-- SphereModel -->
-<g id="node13" class="node">
-<title>SphereModel</title>
-<ellipse fill="none" stroke="#000000" cx="127.8127" cy="-177" rx="63.1228" ry="18"/>
-<text text-anchor="middle" x="127.8127" y="-172.8" font-family="Times,serif" font-size="14.00" fill="#000000">SphereModel</text>
-</g>
-<!-- PointBlock&#45;&gt;SphereModel -->
-<g id="edge7" class="edge">
-<title>PointBlock-&gt;SphereModel</title>
-<path fill="none" stroke="#6495ed" d="M327.7067,-224.4692C288.0552,-215.0531 229.6682,-201.1878 186.2231,-190.8708"/>
-<polygon fill="#6495ed" stroke="#6495ed" points="186.8967,-187.4335 176.3586,-188.5283 185.2793,-194.2441 186.8967,-187.4335"/>
-</g>
-<!-- ImageBlock -->
-<g id="node3" class="node">
-<title>ImageBlock</title>
-<ellipse fill="none" stroke="#000000" cx="372.0522" cy="-150" rx="58.4667" ry="18"/>
-<text text-anchor="middle" x="372.0522" y="-145.8" font-family="Times,serif" font-size="14.00" fill="#000000">ImageBlock</text>
-</g>
-<!-- ImageDepictor -->
-<g id="node8" class="node">
-<title>ImageDepictor</title>
-<ellipse fill="none" stroke="#000000" cx="591.622" cy="-150" rx="68.8984" ry="18"/>
-<text text-anchor="middle" x="591.622" y="-145.8" font-family="Times,serif" font-size="14.00" fill="#000000">ImageDepictor</text>
-</g>
-<!-- ImageBlock&#45;&gt;ImageDepictor -->
-<g id="edge3" class="edge">
-<title>ImageBlock-&gt;ImageDepictor</title>
-<path fill="none" stroke="#6495ed" d="M430.3164,-150C455.4387,-150 485.2159,-150 512.3053,-150"/>
-<polygon fill="#6495ed" stroke="#6495ed" points="512.5407,-153.5001 522.5407,-150 512.5407,-146.5001 512.5407,-153.5001"/>
-</g>
-<!-- ParticleBlock2 -->
-<g id="node4" class="node">
-<title>ParticleBlock2</title>
-<ellipse fill="none" stroke="#000000" cx="372.0522" cy="-96" rx="68.3536" ry="18"/>
-<text text-anchor="middle" x="372.0522" y="-91.8" font-family="Times,serif" font-size="14.00" fill="#000000">ParticleBlock2</text>
-</g>
-<!-- ParticleDepictor2 -->
-<g id="node9" class="node">
-<title>ParticleDepictor2</title>
-<ellipse fill="none" stroke="#000000" cx="591.622" cy="-96" rx="79.2864" ry="18"/>
-<text text-anchor="middle" x="591.622" y="-91.8" font-family="Times,serif" font-size="14.00" fill="#000000">ParticleDepictor2</text>
-</g>
-<!-- ParticleBlock2&#45;&gt;ParticleDepictor2 -->
-<g id="edge4" class="edge">
-<title>ParticleBlock2-&gt;ParticleDepictor2</title>
-<path fill="none" stroke="#6495ed" d="M440.6223,-96C460.1945,-96 481.8113,-96 502.4238,-96"/>
-<polygon fill="#6495ed" stroke="#6495ed" points="502.4487,-99.5001 512.4486,-96 502.4486,-92.5001 502.4487,-99.5001"/>
-</g>
-<!-- LineBlock -->
-<g id="node5" class="node">
-<title>LineBlock</title>
-<ellipse fill="none" stroke="#000000" cx="372.0522" cy="-42" rx="51.6054" ry="18"/>
-<text text-anchor="middle" x="372.0522" y="-37.8" font-family="Times,serif" font-size="14.00" fill="#000000">LineBlock</text>
-</g>
-<!-- LineDepictor -->
-<g id="node10" class="node">
-<title>LineDepictor</title>
-<ellipse fill="none" stroke="#000000" cx="591.622" cy="-42" rx="62.5373" ry="18"/>
-<text text-anchor="middle" x="591.622" y="-37.8" font-family="Times,serif" font-size="14.00" fill="#000000">LineDepictor</text>
-</g>
-<!-- LineBlock&#45;&gt;LineDepictor -->
-<g id="edge5" class="edge">
-<title>LineBlock-&gt;LineDepictor</title>
-<path fill="none" stroke="#6495ed" d="M424.0872,-42C452.3433,-42 487.7346,-42 518.6995,-42"/>
-<polygon fill="#6495ed" stroke="#6495ed" points="519.0143,-45.5001 529.0143,-42 519.0143,-38.5001 519.0143,-45.5001"/>
-</g>
-<!-- FilamentModel -->
-<g id="node12" class="node">
-<title>FilamentModel</title>
-<ellipse fill="none" stroke="#000000" cx="127.8127" cy="-123" rx="70.6626" ry="18"/>
-<text text-anchor="middle" x="127.8127" y="-118.8" font-family="Times,serif" font-size="14.00" fill="#000000">FilamentModel</text>
-</g>
-<!-- LineBlock&#45;&gt;FilamentModel -->
-<g id="edge6" class="edge">
-<title>LineBlock-&gt;FilamentModel</title>
-<path fill="none" stroke="#6495ed" d="M333.3417,-54.2337C318.9682,-58.8146 302.5365,-64.0969 287.6254,-69 251.6012,-80.8457 211.1331,-94.5235 179.933,-105.1494"/>
-<polygon fill="#6495ed" stroke="#6495ed" points="178.4818,-101.9463 170.1461,-108.4859 180.7406,-108.5718 178.4818,-101.9463"/>
-</g>
-<!-- napari -->
-<g id="node14" class="node">
-<title>napari</title>
-<ellipse fill="none" stroke="#00ff00" cx="810.9021" cy="-150" rx="68.2744" ry="18"/>
-<text text-anchor="middle" x="810.9021" y="-145.8" font-family="Times,serif" font-size="14.00" fill="#000000">Napari Viewer</text>
-</g>
-<!-- ParticleDepictor1&#45;&gt;napari -->
-<g id="edge8" class="edge">
-<title>ParticleDepictor1-&gt;napari</title>
-<path fill="none" stroke="#ffd700" d="M643.5614,-244.2371C655.345,-240.4777 667.6553,-236.0306 678.7651,-231 714.6614,-214.7457 752.7296,-190.5813 778.738,-172.8759"/>
-<polygon fill="#ffd700" stroke="#ffd700" points="780.8206,-175.6914 787.0753,-167.1398 776.853,-169.9244 780.8206,-175.6914"/>
-</g>
-<!-- PointDepictor&#45;&gt;napari -->
-<g id="edge9" class="edge">
-<title>PointDepictor-&gt;napari</title>
-<path fill="none" stroke="#ffd700" d="M640.2874,-192.0156C672.9017,-183.984 716.0708,-173.3532 750.8248,-164.7947"/>
-<polygon fill="#ffd700" stroke="#ffd700" points="751.9796,-168.1149 760.8525,-162.3252 750.3057,-161.318 751.9796,-168.1149"/>
-</g>
-<!-- ImageDepictor&#45;&gt;napari -->
-<g id="edge10" class="edge">
-<title>ImageDepictor-&gt;napari</title>
-<path fill="none" stroke="#ffd700" d="M660.7026,-150C683.4485,-150 708.9055,-150 732.3063,-150"/>
-<polygon fill="#ffd700" stroke="#ffd700" points="732.5445,-153.5001 742.5445,-150 732.5444,-146.5001 732.5445,-153.5001"/>
-</g>
-<!-- ParticleDepictor2&#45;&gt;napari -->
-<g id="edge11" class="edge">
-<title>ParticleDepictor2-&gt;napari</title>
-<path fill="none" stroke="#ffd700" d="M645.5448,-109.279C677.493,-117.1466 718.0377,-127.1312 750.9833,-135.2444"/>
-<polygon fill="#ffd700" stroke="#ffd700" points="750.399,-138.705 760.9459,-137.6977 752.0729,-131.908 750.399,-138.705"/>
-</g>
-<!-- LineDepictor&#45;&gt;napari -->
-<g id="edge12" class="edge">
-<title>LineDepictor-&gt;napari</title>
-<path fill="none" stroke="#ffd700" d="M638.0971,-54.0557C651.4788,-58.1504 665.9239,-63.1854 678.7651,-69 714.6614,-85.2543 752.7296,-109.4187 778.738,-127.1241"/>
-<polygon fill="#ffd700" stroke="#ffd700" points="776.853,-130.0756 787.0753,-132.8602 780.8206,-124.3086 776.853,-130.0756"/>
-</g>
-<!-- classify_radial_distance -->
-<g id="node11" class="node">
-<title>classify_radial_distance</title>
-<ellipse fill="none" stroke="#000000" cx="127.8127" cy="-262" rx="103.6257" ry="18"/>
-<text text-anchor="middle" x="127.8127" y="-257.8" font-family="Times,serif" font-size="14.00" fill="#000000">classify_radial_distance</text>
-</g>
-<!-- classify_radial_distance&#45;&gt;ParticleBlock1 -->
-<g id="edge13" class="edge">
-<title>classify_radial_distance-&gt;ParticleBlock1</title>
-<path fill="none" stroke="#00ffff" d="M215.6437,-271.7095C242.8087,-274.7125 272.437,-277.9878 298.5287,-280.8722"/>
-<polygon fill="#00ffff" stroke="#00ffff" points="298.3941,-284.3785 308.7182,-281.9986 299.1634,-277.4209 298.3941,-284.3785"/>
-</g>
-<!-- classify_radial_distance&#45;&gt;ParticleBlock2 -->
-<g id="edge14" class="edge">
-<title>classify_radial_distance-&gt;ParticleBlock2</title>
-<path fill="none" stroke="#00ffff" d="M213.2198,-251.6293C225.808,-247.1276 237.818,-240.816 247.6254,-232 286.0027,-197.5022 250.3747,-158.7113 287.6254,-123 292.9714,-117.8749 299.2911,-113.7226 306.0293,-110.3584"/>
-<polygon fill="#00ffff" stroke="#00ffff" points="307.6702,-113.4607 315.4253,-106.242 304.8612,-107.049 307.6702,-113.4607"/>
-</g>
-<!-- FilamentModel&#45;&gt;ParticleBlock2 -->
-<g id="edge15" class="edge">
-<title>FilamentModel-&gt;ParticleBlock2</title>
-<path fill="none" stroke="#ee82ee" d="M192.6232,-115.8354C225.1539,-112.2392 264.7013,-107.8673 298.3809,-104.1442"/>
-<polygon fill="#ee82ee" stroke="#ee82ee" points="299.0758,-107.5888 308.6306,-103.0111 298.3065,-100.6311 299.0758,-107.5888"/>
-</g>
-<!-- SphereModel&#45;&gt;ParticleBlock1 -->
-<g id="edge16" class="edge">
-<title>SphereModel-&gt;ParticleBlock1</title>
-<path fill="none" stroke="#ee82ee" d="M165.0153,-191.654C189.2079,-201.7648 221.0748,-216.1955 247.6254,-232 266.7206,-243.3666 267.8788,-251.807 287.6254,-262 295.6591,-266.1469 304.452,-269.8124 313.2324,-272.9991"/>
-<polygon fill="#ee82ee" stroke="#ee82ee" points="312.124,-276.3191 322.7187,-276.2693 314.4053,-269.7013 312.124,-276.3191"/>
-</g>
-</g>
-</svg>
\ No newline at end of file
diff --git a/docs/getting_started.md b/docs/getting_started.md
new file mode 100644
index 00000000..11fb1a94
--- /dev/null
+++ b/docs/getting_started.md
@@ -0,0 +1,33 @@
+# Getting started
+
+## Loading data
+
+From the command line:
+```bash
+peep /path/to.star /path/to/mrc/files/
+```
+
+Alternatively, use PeepingTom from within python directly (it's designed with ipython in mind):
+```python
+import peepingtom as pt
+
+peeper = pt.peep('path_to.mrc')
+peeper.show()
+```
+
+## Navigating your data
+In the napari GUI, you'll find the PeepingTom widget at the bottom left. Select which volume to visualize with the dropdown menu.
+Alternatively, you can use `PageUp` and `PageDown` to switch to the previous or next volume.
+
+All napari functionality works as normal, and `peeper` will keep track of which layers come from where. Feel free to add any custom layer: peepingtom won't interfere with them (if it does, please report it in an issue!).
+
+```{note}
+Unless you manually generated plots and assigned them to volumes, the `Show / Hide Plots` button will show an empty widget. This is a feature ready for future versions!
+```
+
+To get a closer look at all the `DataBlocks` contained in your `Peeper`, try some of the following:
+```python
+peeper
+peeper.volumes
+peeper.particles
+```
diff --git a/docs/images/favicon.ico b/docs/images/favicon.ico
new file mode 100644
index 00000000..5ea6f758
Binary files /dev/null and b/docs/images/favicon.ico differ
diff --git a/docs/images/logo.png b/docs/images/logo.png
new file mode 100644
index 00000000..d3447c76
Binary files /dev/null and b/docs/images/logo.png differ
diff --git a/docs/index.md b/docs/index.md
new file mode 100644
index 00000000..2cb706b3
--- /dev/null
+++ b/docs/index.md
@@ -0,0 +1,4 @@
+```{include} ../README.md
+:relative-docs: docs/
+:relative-images:
+```
diff --git a/docs/requirements.txt b/docs/requirements.txt
new file mode 100644
index 00000000..65b1f157
--- /dev/null
+++ b/docs/requirements.txt
@@ -0,0 +1,2 @@
+jupyter-book
+sphinx_material
diff --git a/docs/tips_and_tricks.md b/docs/tips_and_tricks.md
new file mode 100644
index 00000000..3c56562e
--- /dev/null
+++ b/docs/tips_and_tricks.md
@@ -0,0 +1,39 @@
+# Tips and Tricks
+
+## File names and volumes
+To load your data into a `Peeper`, peepingtom needs to know how to divide it in "volumes". By default, peepingtom will try to split everything based on file names (or things like the `rlnMicrographName` filed in `.star` files) and using some [regex pattern](https://en.wikipedia.org/wiki/Regular_expression) to decide which data belongs to the same 3D volumes.
+
+By default, peepingtom recognizes files containing `TS_\d+`, and will fall back to `\d+`. For example, the following will be part of the `TS_001` volume:
+```
+CoolSample2_TS_001_something.mrc
+Particles_TS_001.star
+```
+
+These, instead, will be put simply in `02`:
+```
+image_02.em
+particles_02.tbl
+```
+
+Anything else ends up in its own `None_X` volume. If your data follows a different naming scheme, you can provide a [custom regex pattern](https://regex101.com/):
+```python
+pt.peep(YOUR_DATA, name_regex='\d_\w+')
+```
+
+## DispatchList
+To navigate nested attributes in `Peeper`, you can use the convenient `DispatchList`. Some methods of `Peeper` return this type of list, which is printed as `*[elements]*`. Try:
+```python
+peeper.datablocks.name
+# *[name1, name2, name3, ...]*
+```
+
+It works for method calls and setting values...
+```python
+peeper.particles.pixel_size = 10
+peeper.particles.
+```
+
+... and you can even dispatch getitem calls with `.disp`, similarly to pandas' `.loc`:
+```python
+peeper.datablocks.disp[:3]  # returns a view of the first 3 elements (if possible) of each datablock
+```
diff --git a/peepingtom/__init__.py b/peepingtom/__init__.py
index 8a953487..81969ddc 100644
--- a/peepingtom/__init__.py
+++ b/peepingtom/__init__.py
@@ -1,2 +1,8 @@
+try:
+    from ._version import version as __version__
+except ImportError:
+    __version__ = "not-installed"
+
 from .io_ import read, write
-from .peeper import peep, Peeper
+from .functions import peep
+from .peeper import Peeper
diff --git a/peepingtom/__main__.py b/peepingtom/__main__.py
new file mode 100644
index 00000000..fd26ad3c
--- /dev/null
+++ b/peepingtom/__main__.py
@@ -0,0 +1,85 @@
+import sys
+
+from IPython.terminal.embed import InteractiveShellEmbed
+import click
+
+import peepingtom as pt
+
+
+@click.command(context_settings=dict(help_option_names=['-h', '--help']))
+@click.argument('paths', nargs=-1)
+@click.option('-m', '--mode', type=click.Choice(['lone', 'zip_by_type', 'bunch']),
+              help='how to arrange DataBlock into volumes [default: guess from input]')
+@click.option('-n', '--name-regex', metavar='regex',
+              help=r'a regex used to infer DataBlock names from paths [fallback: \d+]')
+@click.option('-p', '--pixel-size',
+              help='manually set the pixel size (overrides the one read from file)')
+@click.option('--name', help='the name of the generated Peeper')
+@click.option('-d', '--dry-run', is_flag=True,
+              help='only show the list of files that would be read')
+@click.option('--strict', is_flag=True,
+              help='immediately fail if a matched path cannot be read')
+@click.option('--mmap', is_flag=True,
+              help='open file in memory map mode (if possible)')
+@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 Peeper, without showing the data in napari')
+def cli(paths, mode, name_regex, pixel_size, dry_run, strict, name, mmap, lazy, no_show):
+    """
+    PeepingTom command line interface.
+
+    Opens files in napari and lands in an interactive ipython shell
+    with peepingtom imported as `pt` and the initialised Peeper available as `p`.
+
+    PATHS: any number of files or globs [default='./*']
+
+    \b
+    MODE choices:
+      - lone: each datablock in a separate volume
+      - zip_by_type: one of each datablock type per volume
+      - bunch: all datablocks in a single volume
+
+    \b
+    EXAMPLES:
+    Open a .star file as particles:
+        peep particles.star
+    Open particles and images from a directory:
+        peep /dir/with/star_and_mrc_files/
+    Match files such as MyProtein_10.star and MyProtein_001.mrc,
+    and name the respective DataBlocks Protein_10 and Protein_001:
+        peep /path/to/dir/MyProtein* -n 'Protein_\d+'
+    """  # noqa: W605
+    if not paths:
+        paths = ['./*']
+
+    if dry_run:
+        files = pt.io_.find_files(paths)
+        print('Files found:')
+        print(*(str(file) for file in files), sep='\n')
+        sys.exit()
+
+    peeper = pt.read(*paths,  # noqa: F841
+                     name=name,
+                     mode=mode,
+                     name_regex=name_regex,
+                     pixel_size=pixel_size,
+                     strict=strict,
+                     mmap=mmap,
+                     lazy=lazy,
+                     )
+
+    # set up ipython shell nicely
+    banner = '''=== PeepingTom ===
+initialised variables:
+    - peeper
+    - viewer
+    '''
+    sh = InteractiveShellEmbed(banner2=banner)
+    sh.enable_gui('qt')
+    sh.push('peeper')
+    if not no_show:
+        sh.run_cell('peeper.show()', silent=True)
+    viewer = peeper.napari_viewer  # noqa: F841
+    sh.push('viewer')
+    sh()
diff --git a/peepingtom/_version.py b/peepingtom/_version.py
deleted file mode 100644
index b794fd40..00000000
--- a/peepingtom/_version.py
+++ /dev/null
@@ -1,1 +0,0 @@
-__version__ = '0.1.0'
diff --git a/peepingtom/analysis/__init__.py b/peepingtom/analysis/__init__.py
deleted file mode 100644
index 8b137891..00000000
--- a/peepingtom/analysis/__init__.py
+++ /dev/null
@@ -1,1 +0,0 @@
-
diff --git a/peepingtom/analysis/particles.py b/peepingtom/analysis/particles.py
deleted file mode 100644
index f5980cd4..00000000
--- a/peepingtom/analysis/particles.py
+++ /dev/null
@@ -1,58 +0,0 @@
-"""
-Analysis functions that operate on collections of data object
-"""
-
-import numpy as np
-from scipy.spatial import cKDTree
-from scipy.ndimage import convolve1d
-from scipy.cluster.vq import kmeans2
-from scipy.signal.windows import gaussian
-
-
-def classify_radial_distance(particles, class_tag='class_radial', max_r=50, n_shells=100,
-                             n_classes=5, convolve=True, cv_window=20, std=5, inplace=True,
-                             use_old_matrix=True):
-    """
-    classify particles based on the number of neighbors in radial shells
-    """
-    shell_width = max_r / n_shells
-    binned = []
-    # calculate adjacency matrix or reuse old one
-    for part in particles:
-        redo = True
-        if f'{class_tag}_params' in part.properties:
-            old_max_r = part.properties[f'{class_tag}_params']['max_r']
-            if max_r <= old_max_r:
-                adj_matrix = part.properties[f'{class_tag}_adj_matrix']
-                redo = False
-        if not use_old_matrix or redo:
-            tree = cKDTree(part.positions.data)
-            adj_matrix = tree.sparse_distance_matrix(tree, max_r).toarray()
-            if inplace:
-                part.properties[f'{class_tag}_adj_matrix'] = adj_matrix
-
-        # calculate shells
-
-        shells = [np.sum((adj_matrix > i * shell_width) & (adj_matrix <= (i + 1) * shell_width), axis=1)
-                  for i in range(n_shells)]
-        binned.append(np.stack(shells, axis=1).astype(float))
-    binned = np.concatenate(binned)
-
-    if convolve:
-        binned = convolve1d(binned, gaussian(cv_window, std))
-    centroids, classes = kmeans2(binned, n_classes, iter=100, minit='points')
-
-    if inplace:
-        start = end = 0
-        for part in particles:
-            end += part.positions.data.shape[0]
-            sliced_classes = classes[start:end]
-            part.properties[class_tag] = sliced_classes
-            start += part.positions.data.shape[0]
-
-            part.properties[f'{class_tag}_centroids'] = centroids
-            part.properties[f'{class_tag}_params'] = {
-                'max_r': max_r
-            }
-
-    return centroids, classes
diff --git a/peepingtom/core/__init__.py b/peepingtom/core/__init__.py
deleted file mode 100644
index 1dce3c55..00000000
--- a/peepingtom/core/__init__.py
+++ /dev/null
@@ -1,7 +0,0 @@
-from .alchemists import Alchemist, PointToLineAlchemist, PointToParticleAlchemist
-from .datablocks import DataBlock, SimpleBlock, MultiBlock, DataCrate, PointBlock, \
-    LineBlock, OrientationBlock, ImageBlock, LineBlock, SphereBlock, PropertyBlock, \
-    ParticleBlock, MeshBlock, DipoleBlock, TransformBlock
-from .models import Model
-
-from .functions import merge, stack
diff --git a/peepingtom/core/alchemists/__init__.py b/peepingtom/core/alchemists/__init__.py
deleted file mode 100644
index ab3385bb..00000000
--- a/peepingtom/core/alchemists/__init__.py
+++ /dev/null
@@ -1,2 +0,0 @@
-from .base import Alchemist
-from .from_points import PointToLineAlchemist, PointToParticleAlchemist
diff --git a/peepingtom/core/alchemists/base.py b/peepingtom/core/alchemists/base.py
deleted file mode 100644
index 7085f47f..00000000
--- a/peepingtom/core/alchemists/base.py
+++ /dev/null
@@ -1,19 +0,0 @@
-from abc import ABC, abstractmethod
-
-
-class Alchemist(ABC):
-    """
-    Base class for all alchemist objects, used to transform a datablocks into other datablocks
-    """
-    def __init__(self, input_blocks):
-        self.inputs = input_blocks
-        self.outputs = {}
-
-    def __repr__(self):
-        return f'<{type(self).__name__}>'
-
-    @abstractmethod
-    def transform(self):
-        """
-        generate the output blocks based on the current settings and the input blocks
-        """
diff --git a/peepingtom/core/alchemists/from_points.py b/peepingtom/core/alchemists/from_points.py
deleted file mode 100644
index 77e76c44..00000000
--- a/peepingtom/core/alchemists/from_points.py
+++ /dev/null
@@ -1,37 +0,0 @@
-import numpy as np
-
-from ..datablocks import LineBlock, ParticleBlock, PointBlock, OrientationBlock, PropertyBlock
-from .base import Alchemist
-
-
-class PointToLineAlchemist(Alchemist):
-    """
-    transform a PointBlock into a LineBlock
-    """
-    def __init__(self, pointblock):
-        super().__init__({'points': pointblock})
-
-    def transform(self):
-        points = self.inputs['points'].data
-        if 'line' not in self.outputs:
-            self.outputs['line'] = LineBlock(points)
-        else:
-            self.outputs['line'].data = points
-
-
-class PointToParticleAlchemist(Alchemist):
-    """
-    transform a PointBlock into a ParticleBlock
-    """
-    def __init__(self, pointblock):
-        super().__init__({'points': pointblock})
-
-    def transform(self):
-        points = self.inputs['points'].data
-        if 'particles' not in self.outputs:
-            pointblock = PointBlock(points)
-            oriblock = OrientationBlock(np.zeros((pointblock.n, pointblock.ndim, pointblock.ndim)))
-            propblock = PropertyBlock({})
-            self.outputs['particles'] = ParticleBlock(pointblock, oriblock, propblock)
-        else:
-            self.outputs['particles'].positions.data = points
diff --git a/peepingtom/core/datablocks/__init__.py b/peepingtom/core/datablocks/__init__.py
deleted file mode 100644
index e277f24a..00000000
--- a/peepingtom/core/datablocks/__init__.py
+++ /dev/null
@@ -1,11 +0,0 @@
-from .base import DataBlock, SimpleBlock, MultiBlock, DataCrate
-from .dipoleblock import DipoleBlock
-from .imageblock import ImageBlock
-from .lineblock import LineBlock
-from .meshblock import MeshBlock
-from .orientationblock import OrientationBlock
-from .particleblock import ParticleBlock
-from .pointblock import PointBlock
-from .propertyblock import PropertyBlock
-from .sphereblock import SphereBlock
-from .transformblock import TransformBlock
diff --git a/peepingtom/core/datablocks/base.py b/peepingtom/core/datablocks/base.py
deleted file mode 100644
index 41f4627e..00000000
--- a/peepingtom/core/datablocks/base.py
+++ /dev/null
@@ -1,352 +0,0 @@
-from typing import List
-from collections.abc import Iterable
-
-from ...utils import AttributedList
-
-
-class DataBlock:
-    """
-    Base class for all simple and complex datablocks.
-    Provides common methods and easy type inference
-    """
-    def __init__(self, name=None, parent=None, depictor=None):
-        if name is None:
-            name = 'NoName'
-        self.name = name
-        self.parent = parent
-        self.depictor = depictor
-
-    def dump(self):
-        kwargs = {}
-        kwargs.update({'parent': self.parent, 'depictor': self.depictor})
-        return kwargs
-
-    def updated(self):
-        """
-        this function is called when the data changed in order to trigger callbacks
-        """
-        if self.depictor is not None:
-            self.depictor.update()
-
-    def __newlike__(self, *args, **kwargs):
-        # this makes sure that operators get the right output in case
-        # _merge or _stack return notimplemented
-        if args:
-            if args[0] is NotImplemented:
-                return NotImplemented
-        cls = type(self)
-        return cls(parent=self.parent, *args, **kwargs)
-
-    def __shape_repr__(self):
-        return ''
-
-    def __base_repr__(self):
-        return f'{type(self).__name__}[{self.name}]{self.__shape_repr__()}'
-
-    def __repr__(self):
-        return f'<{self.__base_repr__()}>'
-
-    def __and__(self, other):
-        if isinstance(other, DataBlock):
-            return DataCrate([self, other])
-        elif isinstance(other, DataCrate):
-            return DataCrate(self) + other
-        else:
-            return NotImplemented
-
-    def __iand__(self, other):
-        return NotImplemented
-
-    @staticmethod
-    def _merge_data(datablocks):
-        """
-        convenience method to merge the data of several datablocks
-        of the same type into one, within the same ndimensional space
-        used by merge and imerge.
-        """
-        return NotImplemented
-
-    @staticmethod
-    def _stack_data(datablocks):
-        """
-        convenience method to stack the data of several datablocks into one.
-        If dimensionality is the same, add a new dimension; otherwise,
-        use the next available dimension for the datablocks with smaller dimensionality
-        used by stack and istack.
-        """
-        return NotImplemented
-
-    def _merge(self, datablocks):
-        """
-        merge several datablocks and return a `newlike` object
-        self is not part of merged objects
-        """
-        return NotImplemented
-
-    def _stack(self, datablocks):
-        """
-        stack several datablocks and return a `newlike` object
-        self is not part of stacked objects
-        """
-        return NotImplemented
-
-    def _imerge(self, datablocks):
-        """
-        like merge, but inplace. Self is part of merged objects.
-        """
-        return NotImplemented
-
-    def _istack(self, datablocks):
-        """
-        like stack, but inplace. Self is part of stacked objects.
-        """
-        return NotImplemented
-
-    def __add__(self, other):
-        if isinstance(other, type(self)):
-            return self._merge([self, other])
-        else:
-            return NotImplemented
-
-    def __iadd__(self, other):
-        if isinstance(other, type(self)):
-            self._imerge([other])
-            return self
-        else:
-            return NotImplemented
-
-    def __or__(self, other):
-        if isinstance(other, type(self)):
-            return self._stack([self, other])
-        else:
-            return NotImplemented
-
-    def __ior__(self, other):
-        if isinstance(other, type(self)):
-            self._istack([other])
-            return self
-        else:
-            return NotImplemented
-
-
-class SimpleBlock(DataBlock):
-    """
-    Base class for all simple DataBlock objects, data types which can be visualised by Depictors
-
-    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
-    """
-
-    def __init__(self, data, **kwargs):
-        super().__init__(**kwargs)
-        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
-        else:
-            self._data = self._data_setter(data)
-        self.updated()
-
-    def _data_setter(self, data):
-        """
-        takes raw data and returns it properly formatted to the SimpleBlock subclass specification.
-        """
-        raise NotImplementedError('SimpleBlocks must implement this method')
-
-    def dump(self):
-        kwargs = super().dump()
-        kwargs.update({'data': self.data})
-        return kwargs
-
-    def __getitem__(self, key):
-        return self.data.__getitem__(key)
-
-    def __setitem__(self, key, value):
-        self.data.__setitem__(key, value)
-        self.updated()
-
-    def __delitem__(self, key):
-        self.data.__delitem__(key)
-        self.updated()
-
-    def __contains__(self, item):
-        return self.data.__contains__(item)
-
-    def __len__(self):
-        return self.data.__len__()
-
-    def __iter__(self):
-        return self.data.__iter__()
-
-    def __reversed__(self):
-        return self.data.__reversed__()
-
-    def _merge(self, datablocks):
-        return self.__newlike__(self._merge_data(datablocks))
-
-    def _stack(self, datablocks):
-        return self.__newlike__(self._stack_data(datablocks))
-
-    def _imerge(self, datablocks):
-        self.data = self._merge_data([self] + datablocks)
-
-    def _istack(self, datablocks):
-        self.data = self._stack_data([self] + datablocks)
-
-
-class MultiBlock(DataBlock):
-    """
-    Unites multiple SimpleBlocks into a more complex data object
-
-    Note: classes which inherit from 'MultiBlock' should call Super().__init__
-    first in their constructors so that references to blocks are correctly defined
-    """
-    def __init__(self, **kwargs):
-        """
-        Parameters
-        ----------
-        kwargs : keyword arguments which get passed down to DataBlock
-        """
-        super().__init__(**kwargs)
-        self.blocks = []
-
-    def __setattr__(self, name, value):
-        """
-        Extend the functionality of __setattr__ to automatically add datablocks to the
-        'blocks' attribute of a 'MultiBlock' when set
-        """
-        if isinstance(value, SimpleBlock):
-            self._add_block(value)
-        super().__setattr__(name, value)
-
-    @property
-    def blocks(self):
-        return self._blocks
-
-    @blocks.setter
-    def blocks(self, blocks: List[SimpleBlock]):
-        if not isinstance(blocks, list):
-            raise ValueError("blocks in a multiblock object must be a list of 'SimpleBlock' objects")
-        self._blocks = blocks
-
-    def _add_block(self, block: SimpleBlock):
-        """
-        Adds a block to an existing list of SimpleBlocks in a MultiBlock
-
-        This is particularly useful when extending the functionality of an existing
-        MultiBlock object by inheritance
-        """
-        self._blocks.append(block)
-
-    @staticmethod
-    def _merge_data(multiblocks):
-        blocks_data = []
-        blocks_all = [mb.blocks for mb in multiblocks]
-        # cryptic loop example: datablock types in "blocks" (a, b, c),
-        # this loops through the list [(a1, a2, ...), (b1, b2, ...), (c1, c2, ...)]
-        # so this separates the components of a list of multiblocks into a lists of
-        # simple datablocks of the same type
-        for blocks_by_type in zip(*blocks_all):
-            blocks_data.append(blocks_by_type[0]._merge_data(blocks_by_type))
-        return blocks_data
-
-    @staticmethod
-    def _stack_data(multiblocks):
-        blocks_data = []
-        blocks_all = [mb.blocks for mb in multiblocks]
-        # cryptic loop example: datablock types in "blocks" (a, b, c),
-        # this loops through the list [(a1, a2, ...), (b1, b2, ...), (c1, c2, ...)]
-        # so this separates the components of a list of multiblocks into a lists of
-        # simple datablocks of the same type
-        for blocks_by_type in zip(*blocks_all):
-            blocks_data.append(blocks_by_type[0]._stack_data(blocks_by_type))
-        return blocks_data
-
-    def _merge(self, multiblocks):
-        return self.__newlike__(*self._merge_data(multiblocks))
-
-    def _stack(self, multiblocks):
-        return self.__newlike__(*self._stack_data(multiblocks))
-
-    def _imerge(self, multiblocks):
-        new_data = self._merge_data([self] + multiblocks)
-        for block, data in zip(self.blocks, new_data):
-            block.data = data
-
-    def _istack(self, multiblocks):
-        new_data = self._stack_data([self] + multiblocks)
-        for block, data in zip(self.blocks, new_data):
-            block.data = data
-
-
-class DataCrate(AttributedList):
-    """
-    A container for DataBlock objects which exist within the same n-dimensional reference space
-    """
-    def __init__(self, iterable_or_datablock=()):
-        # recursively unpack the iterable into datablocks only
-        def unpack(iterable):
-            datablocks = []
-            if isinstance(iterable, (list, tuple)):
-                for item in iterable:
-                    datablocks.extend(unpack(item))
-            else:
-                datablocks.append(iterable)
-            return datablocks
-
-        items = unpack(iterable_or_datablock)
-        self._checktypes(items)
-        super().__init__(items)
-
-    @staticmethod
-    def _checktypes(items):
-        for item in items:
-            if not isinstance(item, DataBlock):
-                raise TypeError(f'DataCrate can only hold DataBlocks, not {type(item)}')
-
-    def __add__(self, other):
-        if isinstance(other, list):
-            self._checktypes(other)
-            return DataCrate(super().__add__(other))
-        if isinstance(other, DataBlock):
-            return self + DataCrate([other])
-        else:
-            return NotImplemented
-
-    def __iadd__(self, other):
-        if isinstance(other, list):
-            self._checktypes(other)
-            super().__iadd__(other)
-            return self
-        if isinstance(other, DataBlock):
-            super().__iadd__([other])
-            return self
-        else:
-            return NotImplemented
-
-    def __and__(self, other):
-        if isinstance(other, (list, DataBlock)):
-            return self + other
-        else:
-            return NotImplemented
-
-    def __iand__(self, other):
-        if isinstance(other, (list, DataBlock)):
-            self += other
-            return self
-        else:
-            return NotImplemented
-
-    def __base_repr__(self):
-        return f'{type(self).__name__}({len(self)})'
-
-    def __repr__(self):
-        return f'<{self.__base_repr__()}: [{", ".join([datablock.__base_repr__() for datablock in self])}]>'
diff --git a/peepingtom/core/datablocks/imageblock.py b/peepingtom/core/datablocks/imageblock.py
deleted file mode 100644
index 70053034..00000000
--- a/peepingtom/core/datablocks/imageblock.py
+++ /dev/null
@@ -1,40 +0,0 @@
-import numpy as np
-
-from .base import SimpleBlock
-
-
-class ImageBlock(SimpleBlock):
-    """
-    n-dimensional image block
-    data can be interpreted as n-dimensional images
-    """
-
-    def __init__(self, data, pixel_size=1, **kwargs):
-        super().__init__(data, **kwargs)
-        self.pixel_size = pixel_size
-
-    def _data_setter(self, image: np.ndarray):
-        return np.array(image)
-
-    @property
-    def pixel_size(self):
-        return self._pixel_size
-
-    @pixel_size.setter
-    def pixel_size(self, value):
-        self._pixel_size = float(value)
-
-    def ndim(self):
-        return self.data.ndim
-
-    def dump(self):
-        kwargs = super().dump()
-        kwargs.update({'data': self.data})
-        return kwargs
-
-    @staticmethod
-    def _stack_data(datablocks):
-        return np.stack([db.data for db in datablocks])
-
-    def __shape_repr__(self):
-        return f'{self.data.shape}'
diff --git a/peepingtom/core/datablocks/meshblock.py b/peepingtom/core/datablocks/meshblock.py
deleted file mode 100644
index 55bcf9b1..00000000
--- a/peepingtom/core/datablocks/meshblock.py
+++ /dev/null
@@ -1,59 +0,0 @@
-import numpy as np
-
-from .pointblock import PointBlock
-
-
-class MeshBlock(PointBlock):
-    """
-    Data structure for holding n-dimensional mesh data
-    """
-    def __init__(self, vertices: np.ndarray, faces: np.ndarray, **kwargs):
-        """
-
-        Parameters
-        ----------
-        vertices : array of shape (v, m)
-                   v vertices in m dimensions
-        faces : array of shape (f, 3)
-                array of indices into the vertices defining connectivity
-        kwargs : keyword arguments passed to SimpleBlock
-        """
-        super().__init__(data=vertices, **kwargs)
-        self.vertices = vertices
-        self.faces = faces
-        self.data = self.vertices
-
-    @property
-    def vertices(self):
-        return self._vertices
-
-    @vertices.setter
-    def vertices(self, vertices: np.ndarray):
-        self._vertices = np.asarray(vertices)
-
-    @property
-    def faces(self):
-        return self._faces
-
-    @faces.setter
-    def faces(self, faces: np.ndarray):
-        faces = np.asarray(faces)
-        if faces.shape[1] != 3:
-            raise ValueError('shape of faces must be (f, 3)')
-        self._faces = faces
-
-    @property
-    def triangles(self):
-        triangles = self.vertices[self.faces]
-        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)
diff --git a/peepingtom/core/datablocks/orientationblock.py b/peepingtom/core/datablocks/orientationblock.py
deleted file mode 100644
index 94e33c7c..00000000
--- a/peepingtom/core/datablocks/orientationblock.py
+++ /dev/null
@@ -1,149 +0,0 @@
-import numpy as np
-from eulerangles import euler2matrix
-
-from .base import SimpleBlock
-
-
-class OrientationBlock(SimpleBlock):
-    """
-    OrientationBlock objects represent orientations in a 2d or 3d space
-
-    Contains factory methods for instantiation from eulerian angles
-
-    rotation_matrices : (n, 2, 2) or (n, 3, 3) array of rotation matrices R
-                        R should satisfy Rv = v' where v is a column vector
-
-    """
-    def _data_setter(self, data: np.ndarray):
-        data = np.array(data)
-        # check for single matrix case and assert dimensionality
-        val_error = ValueError(f'rotation matrices should be of shape '
-                               f'(2, 2), (3, 3), (n, 2, 2) or (n, 3, 3), '
-                               f'not {data.shape}')
-        if data.ndim == 1:
-            raise val_error
-        if not data.shape[-1] == data.shape[-2]:
-            raise val_error
-        if not data.shape[-1] in (2, 3):
-            raise val_error
-
-        if data.ndim == 2:
-            m = data.shape[-1]
-            data = data.reshape((1, m, m))
-
-        return data
-
-    @property
-    def n(self):
-        return len(self)
-
-    @property
-    def ndim(self):
-        """
-        as ndim for numpy arrays, but treating the vectors as a sparse matrix.
-        returns the number of dimensions (spatial or not) describing the vectors
-        """
-        return self.data.shape[-1]
-
-    @classmethod
-    def from_euler_angles(cls, euler_angles: np.ndarray, axes: str, intrinsic: bool, positive_ccw: bool,
-                          invert_matrix: bool):
-        """
-        Factory method for creating a VectorBlock directly from a set of eulerian angles
-
-        Parameters
-        ----------
-        euler_angles : ndarray, (n, 3) array of n sets of eulerian angles in degrees
-        axes : str, 3 characters from 'x', 'y' and 'z' representing axes around which rotations
-               occur in the euler angles
-        intrinsic : bool, are the euler angles describing a set of intrinsic (rotating reference frame)
-                    or extrinsic (fixed reference frame) rotations
-        positive_ccw : bool, are positive euler angles referring to counterclockwise rotations of vectors
-                       when looking from a positive point along the axis towards the origin
-        invert_matrix : bool, should the matrix be inverted?
-                        this is useful if your euler angles describe the rotation of a target to a source
-                        but you would like your rotation matrices to describe the rotation of a source to
-                        align it with a target
-
-        Returns
-        -------
-
-        """
-        # Calculate rotation matrices
-        rotation_matrices = euler2matrix(euler_angles, axes=axes, intrinsic=intrinsic,
-                                         positive_ccw=positive_ccw)
-
-        # invert matrix if required
-        if invert_matrix:
-            rotation_matrices = rotation_matrices.transpose((-1, -2))
-
-        return cls(rotation_matrices)
-
-    def _calculate_matrix_product(self, vector: np.ndarray):
-        """
-        Calculates the matrix product (v') of the orientation matrices (R) in this
-        VectorBlock object with a given vector (v)
-        Rv = v'
-
-        Parameters
-        ----------
-        vector : ndarray v, column vector or set of column vectors to be premultiplied by rotation matrices
-
-        Returns ndarray v', matrix product Rv
-        -------
-
-        """
-        return self.data @ vector
-
-    def _unit_vector(self, axis: str):
-        """
-        Get a unit vector along a specified axis which matches the dimensionality of the VectorBlock object
-
-        Parameters
-        ----------
-        axis : str, named axis 'x', 'y' or 'z'
-
-        Returns unit vector along provided axis with appropriate dimensionality
-        -------
-
-        """
-        # check dimensionality
-        if self.ndim > 3:
-            raise NotImplementedError('Unit vector generation for objects with greater '
-                                      'than 3 spatial dimensions is not implemented')
-
-        # initialise unit vector array
-        unit_vector = np.zeros((self.ndim, 1))
-
-        # get index which corresponds to axis for vector
-        axis_to_index = {'x': 0,
-                         'y': 1,
-                         'z': 2}
-        dim_idx = axis_to_index[axis]
-
-        # construct unit vector
-        if dim_idx <= self.ndim:
-            unit_vector[dim_idx, 0] = 1
-        else:
-            raise ValueError(f"You asked for axis {axis} from a {self.ndim}d object")
-
-        return unit_vector
-
-    def oriented_vectors(self, axis):
-        return self._calculate_matrix_product(self._unit_vector(axis))
-
-    def dump(self):
-        kwargs = super().dump()
-        kwargs.update({'orientation_block': self.data})
-        return kwargs
-
-    @staticmethod
-    def _merge_data(datablocks):
-        return np.concatenate([db.data for db in datablocks])
-
-    @staticmethod
-    def _stack_data(datablocks):
-        return np.concatenate([db.data for db in datablocks])
-
-    def __shape_repr__(self):
-        return f'({self.n}, {self.ndim})'
diff --git a/peepingtom/core/datablocks/orientedpointblock.py b/peepingtom/core/datablocks/orientedpointblock.py
deleted file mode 100644
index f185761c..00000000
--- a/peepingtom/core/datablocks/orientedpointblock.py
+++ /dev/null
@@ -1,15 +0,0 @@
-import numpy as np
-
-from .base import MultiBlock
-from .orientationblock import OrientationBlock
-from .pointblock import PointBlock
-
-
-class OrientedPointBlock(MultiBlock):
-    def __init__(self, positions: np.ndarray, orientations: np.ndarray, **kwargs):
-        super().__init__(**kwargs)
-        self.positions = PointBlock(positions)
-        self.orientations = OrientationBlock(orientations)
-
-    def __shape_repr__(self):
-        return f'{self.positions.data.shape}'
diff --git a/peepingtom/core/datablocks/particleblock.py b/peepingtom/core/datablocks/particleblock.py
deleted file mode 100644
index 77f71da0..00000000
--- a/peepingtom/core/datablocks/particleblock.py
+++ /dev/null
@@ -1,16 +0,0 @@
-import numpy as np
-
-from .orientedpointblock import OrientedPointBlock
-from .propertyblock import PropertyBlock
-
-
-class ParticleBlock(OrientedPointBlock):
-    def __init__(self, positions: np.ndarray, orientations: np.ndarray, properties: dict, **kwargs):
-        # Initialise OrientedPointBlock
-        super().__init__(positions, orientations, **kwargs)
-
-        # Add PropertyBlock
-        self.properties = PropertyBlock(properties)
-
-    def __shape_repr__(self):
-        return f'{self.positions.data.shape}'
diff --git a/peepingtom/core/datablocks/pointblock.py b/peepingtom/core/datablocks/pointblock.py
deleted file mode 100644
index a1ff7c7b..00000000
--- a/peepingtom/core/datablocks/pointblock.py
+++ /dev/null
@@ -1,209 +0,0 @@
-import numpy as np
-
-from .base import SimpleBlock
-
-
-class PointBlock(SimpleBlock):
-    """
-    PointBlock objects for representing points with convenience methods
-
-    PointBlock data should be array-like objects of shape (n, m) representing n points in m dimensions
-
-    order of dimensions along m is:
-    2d : (x, y)
-    3d : (x. y, z)
-    nd : (..., x, y, z)
-    """
-    def _data_setter(self, data):
-        # cast as array
-        data = np.asarray(data)
-
-        # coerce 1d point to 2d
-        if data.ndim == 1:
-            data = data.reshape((1, len(data)))
-
-        # check ndim of data
-        if not data.ndim == 2:
-            raise ValueError("data object should have ndim == 2")
-
-        return data
-
-    @property
-    def ndim(self):
-        """
-        as ndim for numpy arrays, but treating the points as a sparse matrix.
-        returns the number of dimensions (spatial or not) describing the points
-        """
-        return self.data.shape[1]
-
-    def _named_dimension_to_spatial_index(self, dim: str):
-        """
-        Gets the index of the named dimension 'x', 'y' or 'z'
-        Parameters
-        ----------
-        dim : str, must be one of 'x', 'y' or 'z'
-
-        Returns data along named dimension
-        -------
-
-        """
-        # sanitise input
-        dim = str(dim.strip().lower())
-
-        # dim to index for 3d or less
-        dim_to_index = {'x': 0,
-                        'y': 1,
-                        'z': 2}
-
-        dim_idx = dim_to_index[dim]
-
-        # check and correct index for higher dimensionality
-        if self.ndim > 3:
-            dim_idx = -dim_idx - 1
-
-        return dim_idx
-
-    def _get_dim_at_spatial_index(self, idx: int):
-        return self[:, idx]
-
-    def _get_named_dimension(self, dim: str, as_type='array'):
-        """
-        Get data for a named dimension or multiple named dimensions of the object
-
-        as_array and as_tuple are only considered when retrieving multiple dimensions in one method call
-        Parameters
-        ----------
-
-        dim : str 'x', 'y', 'z' or a combination thereof
-        as_type : str for return type, only if len(dim) > 1
-                  'array' for ndarray or 'tuple' for tuple return type
-
-        Returns (default) (n,m) ndarray of data along named dimension(s) from m
-                  or tuple of arrays of data along each axis
-        -------
-
-        """
-        if as_type not in ('array', 'tuple'):
-            raise ValueError("Argument 'as_type' must be a string from 'array' or 'tuple'")
-
-        if len(dim) > 1:
-            # split dims up and get each separately
-            data = [self._get_named_dimension(_dim) for _dim in dim]
-
-            # decide on output type and return array or tuple as requested, default to array
-            if as_type == 'array':
-                return np.column_stack(data)
-            elif as_type == 'tuple':
-                return tuple(data)
-
-        else:
-            # get index of named dimension along spatial axis
-            dim_idx = self._named_dimension_to_spatial_index(dim)
-            # index into self to get data
-            return self._get_dim_at_spatial_index(dim_idx)
-
-    @property
-    def x(self):
-        return self._get_named_dimension('x')
-
-    @property
-    def y(self):
-        return self._get_named_dimension('y')
-
-    @property
-    def z(self):
-        return self._get_named_dimension('z')
-
-    @property
-    def xyz(self):
-        return self._get_named_dimension('xyz')
-
-    @property
-    def zyx(self):
-        return self._get_named_dimension('zyx')
-
-    def as_zyx(self):
-        """
-        return the data with the order of the spatial axes switched to 'zyx' style rather than 'xyz'
-
-        Returns
-        -------
-        correct view into data no matter the dimensionality
-        """
-        if self.ndim == 1:
-            return self.data
-        elif self.ndim == 2:
-            # invert last two axes
-            return self.data[:, ::-1]
-        else:
-            # invert only last three axes, leave leading dimensions intact
-            data = np.empty_like(self.data)
-            data[:, :-3] = self.data[:, :-3]
-            data[:, -3:] = self.data[:, -1:-4:-1]
-            return data
-
-    @property
-    def n(self):
-        return len(self)
-
-    @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 dump(self):
-        kwargs = super().dump()
-        kwargs.update({'points': self.data})
-        return kwargs
-
-    @staticmethod
-    def _merge_data(datablocks):
-        return np.concatenate([db.data for db in datablocks])
-
-    @staticmethod
-    def _stack_data(datablocks):
-        # this method assumes that stacked points are always intended to have
-        # indexes at any dimension that's higher than 3
-        dims = [db.ndim for db in datablocks]
-        # only obvious how to stack elements with just 1 dimension of difference
-        if abs(min(dims) - max(dims)) > 1:
-            return NotImplemented
-        # allocate the right size
-        stack_shape = (sum([db.n for db in datablocks]), min(dims) + 1)
-        stacked = np.zeros(stack_shape)
-        previous = 0
-        for idx, db in enumerate(datablocks):
-            current = previous + db.n
-            if db.ndim == max(dims) and db.ndim != min(dims):
-                if len(np.unique(db.data)) != 1:
-                    raise ValueError(f'cannot stack: dimension #{db.ndim} of {db} is not an index')
-                # if the given data is not in order, just give up. TODO: implement something smarter!
-                elif db.data[0, 0] != idx:
-                    raise ValueError(f'cannot stack: the index ({db.data[0,0]}) of {db} does not match')
-                else:
-                    stacked[previous:current] = db.data
-            elif db.ndim == min(dims):
-                stacked[previous:current] = np.concatenate([np.ones((db.n, 1)) * idx, db.data], axis=1)
-            previous = current
-        return stacked
-
-    def __shape_repr__(self):
-        return f'{self.data.shape}'
diff --git a/peepingtom/core/datablocks/propertyblock.py b/peepingtom/core/datablocks/propertyblock.py
deleted file mode 100644
index 2e11f3c8..00000000
--- a/peepingtom/core/datablocks/propertyblock.py
+++ /dev/null
@@ -1,21 +0,0 @@
-from .base import SimpleBlock
-
-
-class PropertyBlock(SimpleBlock):
-    """
-    PropertyBlock is a simple dictionary wrapper for arbitrary data
-    """
-    def _data_setter(self, data):
-        return dict(data)
-
-    def items(self):
-        return self.data.items()
-
-    @staticmethod
-    def _merge_data(datablocks):
-        # TODO: this is probably gonna break
-        return {k: v for db in datablocks for k, v in db.items()}
-
-    @staticmethod
-    def _stack_data(datablocks):
-        return {}
diff --git a/peepingtom/core/datablocks/sphereblock.py b/peepingtom/core/datablocks/sphereblock.py
deleted file mode 100644
index c7ab5cb5..00000000
--- a/peepingtom/core/datablocks/sphereblock.py
+++ /dev/null
@@ -1,37 +0,0 @@
-import numpy as np
-
-from .base import SimpleBlock
-
-
-class SphereBlock(SimpleBlock):
-    def __init__(self, center: np.ndarray, radius: float = None, **kwargs):
-        super().__init__(**kwargs)
-        # TODO: check if below works
-        self.data = center, radius
-        self.edge_point = None
-
-    def _data_setter(self, center: np.ndarray, radius: float = None):
-        self.center = center
-        self.radius = float(radius)
-        return self.center, self.radius
-
-    @property
-    def center(self):
-        return self._center
-
-    @center.setter
-    def center(self, point: np.ndarray):
-        return np.asarray(point).reshape(3)
-
-    @property
-    def edge_point(self):
-        return self._edge_point
-
-    @edge_point.setter
-    def edge_point(self, edge_point: np.ndarray):
-        edge_point = np.asarray(edge_point).reshape(3)
-        self._edge_point = edge_point
-        self._update_radius_from_edge_point()
-
-    def _update_radius_from_edge_point(self):
-        self.radius = np.linalg.norm(self.center - self.edge_point)
diff --git a/peepingtom/core/functions.py b/peepingtom/core/functions.py
deleted file mode 100644
index 35a813c6..00000000
--- a/peepingtom/core/functions.py
+++ /dev/null
@@ -1,18 +0,0 @@
-def merge(datablocks):
-    """
-    merges a list of datablocks into a single datablock
-    """
-    cls = type(datablocks[0])
-    if not all(isinstance(db, cls) for db in datablocks):
-        raise TypeError('cannot merge datablocks of different types')
-    return datablocks[0]._merge(datablocks)
-
-
-def stack(datablocks):
-    """
-    stacks a list of datablocks into a single datablock
-    """
-    cls = type(datablocks[0])
-    if not all(isinstance(db, cls) for db in datablocks):
-        raise TypeError('cannot stack datablocks of different types')
-    return datablocks[0]._stack(datablocks)
diff --git a/peepingtom/core/models/__init__.py b/peepingtom/core/models/__init__.py
deleted file mode 100644
index f5c6926a..00000000
--- a/peepingtom/core/models/__init__.py
+++ /dev/null
@@ -1,1 +0,0 @@
-from .base import Model
diff --git a/peepingtom/core/models/base.py b/peepingtom/core/models/base.py
deleted file mode 100644
index f693b20a..00000000
--- a/peepingtom/core/models/base.py
+++ /dev/null
@@ -1,14 +0,0 @@
-from abc import ABC, abstractmethod
-
-
-class Model(ABC):
-    """
-    A Model is an object from which a set of Particles can be derived
-    """
-
-    def __init__(self, **kwargs):
-        super().__init__(**kwargs)
-
-    @abstractmethod
-    def derive_particles(self):
-        pass
diff --git a/peepingtom/datablocks/__init__.py b/peepingtom/datablocks/__init__.py
new file mode 100644
index 00000000..da0f35f9
--- /dev/null
+++ b/peepingtom/datablocks/__init__.py
@@ -0,0 +1,23 @@
+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/peepingtom/datablocks/abstractblocks/__init__.py b/peepingtom/datablocks/abstractblocks/__init__.py
new file mode 100644
index 00000000..1a9a7328
--- /dev/null
+++ b/peepingtom/datablocks/abstractblocks/__init__.py
@@ -0,0 +1,5 @@
+from .datablock import DataBlock
+from .simpleblock import SimpleBlock
+from .multiblock import MultiBlock
+
+from .spatialblock import SpatialBlock
diff --git a/peepingtom/datablocks/abstractblocks/datablock.py b/peepingtom/datablocks/abstractblocks/datablock.py
new file mode 100644
index 00000000..5bd377a5
--- /dev/null
+++ b/peepingtom/datablocks/abstractblocks/datablock.py
@@ -0,0 +1,152 @@
+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, peeper=None, 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._peeper = peeper
+        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 peeper(self):
+        return self.parent._peeper
+
+    @peeper.setter
+    def peeper(self, peeper):
+        self.parent._peeper = peeper
+
+    @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)
+        self.peeper.extend(datablocks)
+        for db in datablocks:
+            db.volume = self.volume
+
+    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):
+        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/peepingtom/datablocks/abstractblocks/metablock.py b/peepingtom/datablocks/abstractblocks/metablock.py
new file mode 100644
index 00000000..746c23b8
--- /dev/null
+++ b/peepingtom/datablocks/abstractblocks/metablock.py
@@ -0,0 +1,48 @@
+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/peepingtom/datablocks/abstractblocks/metamultiblock.py b/peepingtom/datablocks/abstractblocks/metamultiblock.py
new file mode 100644
index 00000000..d3789e44
--- /dev/null
+++ b/peepingtom/datablocks/abstractblocks/metamultiblock.py
@@ -0,0 +1,37 @@
+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', 'peeper', 'parent', 'view_of',
+                         'pixel_size', 'dims_order', 'ndim')
+
+    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/peepingtom/datablocks/abstractblocks/multiblock.py b/peepingtom/datablocks/abstractblocks/multiblock.py
new file mode 100644
index 00000000..1c0eab80
--- /dev/null
+++ b/peepingtom/datablocks/abstractblocks/multiblock.py
@@ -0,0 +1,45 @@
+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/peepingtom/datablocks/abstractblocks/simpleblock.py b/peepingtom/datablocks/abstractblocks/simpleblock.py
new file mode 100644
index 00000000..3ee1bec9
--- /dev/null
+++ b/peepingtom/datablocks/abstractblocks/simpleblock.py
@@ -0,0 +1,91 @@
+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, lazy_loader=None, **kwargs):
+        """
+        data: a single object (typically a numpy array) containing data for the datablock
+        lazy_loader: a loader function that takes the datablock as argument and
+                     loads data into it and applies any other modification as necessary
+        """
+        super().__init__(**kwargs)
+        if (data is None and lazy_loader is None) or \
+           (data is not None and lazy_loader is not None):
+            raise ValueError('either one of data or lazy_loader must be provided')
+        self._lazy_loader = lazy_loader
+        self._loaded = lazy_loader is None
+        self._data = None
+        self.data = data
+
+    @property
+    def data(self):
+        self.load()
+        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()
+
+    def load(self):
+        if not self._loaded and self._lazy_loader is not None:
+            logger.debug(f'loading data for lazy datablock "{self.__short_repr__()}"')
+            self._lazy_loader(self)
+            self._loaded = True
+
+    def unload(self):
+        if self._loaded and self._lazy_loader is not None:
+            self._data = ()
+            self._loaded = False
+
+    @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 = ''
+        if self._loaded:
+            data_repr = f'\n{self.data.__repr__()}'
+        return f'{self.__short_repr__()}{data_repr}'
diff --git a/peepingtom/datablocks/abstractblocks/spatialblock.py b/peepingtom/datablocks/abstractblocks/spatialblock.py
new file mode 100644
index 00000000..5d4c129e
--- /dev/null
+++ b/peepingtom/datablocks/abstractblocks/spatialblock.py
@@ -0,0 +1,49 @@
+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):
+        pixel_size = self.parent._pixel_size
+        # calculate on get, not on set, so we can set without triggering lazy loader
+        # 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):
+            pixel_size = np.ones(self.ndim)
+        else:
+            pixel_size = np.broadcast_to(pixel_size, self.ndim)
+        return pixel_size
+
+    @pixel_size.setter
+    def pixel_size(self, value):
+        self.parent._pixel_size = value
+        self.update()
+
+    @abstractmethod
+    def _ndim(self):
+        pass
+
+    @property
+    def ndim(self):
+        """
+        return the number of spatial dimensions of this datablock
+        """
+        if hasattr(self, '._loaded') and not self._loaded:
+            return 1
+        # view_of instead of parent, lets us have different ndim for blocks inside multiblock, if ever useful
+        return self.view_of._ndim()
+
diff --git a/peepingtom/datablocks/multiblocks/__init__.py b/peepingtom/datablocks/multiblocks/__init__.py
new file mode 100644
index 00000000..8559007b
--- /dev/null
+++ b/peepingtom/datablocks/multiblocks/__init__.py
@@ -0,0 +1,6 @@
+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/peepingtom/core/datablocks/dipoleblock.py b/peepingtom/datablocks/multiblocks/dipoleblock.py
similarity index 80%
rename from peepingtom/core/datablocks/dipoleblock.py
rename to peepingtom/datablocks/multiblocks/dipoleblock.py
index c91bb7f2..5fa7913e 100644
--- a/peepingtom/core/datablocks/dipoleblock.py
+++ b/peepingtom/datablocks/multiblocks/dipoleblock.py
@@ -1,26 +1,29 @@
 import numpy as np
 
-from .base import MultiBlock
-from .pointblock import PointBlock
-from .orientationblock import OrientationBlock
+from ..abstractblocks import SpatialBlock, MultiBlock
+from ..simpleblocks import PointBlock, OrientationBlock
 from ...utils import align_vectors
 
 
-class DipoleBlock(MultiBlock):
+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, startpoints: np.ndarray, endpoints: np.ndarray, **kwargs):
+    def __init__(self, **kwargs):
         super().__init__(**kwargs)
-        # Set dipole info
-        self.startpoints = PointBlock(startpoints)
-        self.endpoints = PointBlock(endpoints)
-
         # Check endpoint shape matches that of startpoints
         self._check_shapes()
 
+    @property
+    def n(self):
+        return self.startpoints.n
+
+    def _ndim(self):
+        return self.startpoints._ndim()
+
     @property
     def orientation_vectors(self):
         """
@@ -69,4 +72,7 @@ def calculate_orientation_block(self, vector: np.ndarray):
 
         # calculate rotation matrices
         rotation_matrices = align_vectors(normalised_vector, self.normalised_orientation_vectors)
-        return OrientationBlock(rotation_matrices, parent=self)
+        return OrientationBlock(data=rotation_matrices)
+
+    def __shape_repr__(self):
+        return f'({self.n}, {self.ndim})'
diff --git a/peepingtom/datablocks/multiblocks/meshblock.py b/peepingtom/datablocks/multiblocks/meshblock.py
new file mode 100644
index 00000000..43dad50c
--- /dev/null
+++ b/peepingtom/datablocks/multiblocks/meshblock.py
@@ -0,0 +1,32 @@
+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}
+
+    def _ndim(self):
+        return self.vertices._ndim()
+
+    @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)
diff --git a/peepingtom/datablocks/multiblocks/orientedpointblock.py b/peepingtom/datablocks/multiblocks/orientedpointblock.py
new file mode 100644
index 00000000..a10f1add
--- /dev/null
+++ b/peepingtom/datablocks/multiblocks/orientedpointblock.py
@@ -0,0 +1,16 @@
+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
+
+    def _ndim(self):
+        return self.positions._ndim()
+
+    def __shape_repr__(self):
+        return f'({self.n}, {self.ndim})'
diff --git a/peepingtom/datablocks/multiblocks/particleblock.py b/peepingtom/datablocks/multiblocks/particleblock.py
new file mode 100644
index 00000000..77fc83f2
--- /dev/null
+++ b/peepingtom/datablocks/multiblocks/particleblock.py
@@ -0,0 +1,37 @@
+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/peepingtom/datablocks/multiblocks/sphereblock.py b/peepingtom/datablocks/multiblocks/sphereblock.py
new file mode 100644
index 00000000..d4cc8df8
--- /dev/null
+++ b/peepingtom/datablocks/multiblocks/sphereblock.py
@@ -0,0 +1,17 @@
+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}
+
+    def set_radii_from_edge_points(self, edge_points):
+        self.radii.data = np.linalg.norm(edge_points - self.centers, axis=-1)
+
+    def _ndim(self):
+        return self.centers._ndim()
diff --git a/peepingtom/core/datablocks/transformblock.py b/peepingtom/datablocks/multiblocks/transformblock.py
similarity index 96%
rename from peepingtom/core/datablocks/transformblock.py
rename to peepingtom/datablocks/multiblocks/transformblock.py
index acf7f6fa..d2242880 100644
--- a/peepingtom/core/datablocks/transformblock.py
+++ b/peepingtom/datablocks/multiblocks/transformblock.py
@@ -1,8 +1,7 @@
 import numpy as np
 
 from .particleblock import ParticleBlock
-from .pointblock import PointBlock
-from .orientationblock import OrientationBlock
+from ..simpleblocks import PointBlock, OrientationBlock
 
 
 class TransformBlock(ParticleBlock):
@@ -14,7 +13,6 @@ class TransformBlock(ParticleBlock):
     * 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
diff --git a/peepingtom/datablocks/simpleblocks/__init__.py b/peepingtom/datablocks/simpleblocks/__init__.py
new file mode 100644
index 00000000..5937c8aa
--- /dev/null
+++ b/peepingtom/datablocks/simpleblocks/__init__.py
@@ -0,0 +1,5 @@
+from .imageblock import ImageBlock
+from .lineblock import LineBlock
+from .orientationblock import OrientationBlock
+from .pointblock import PointBlock
+from .propertyblock import PropertyBlock
diff --git a/peepingtom/datablocks/simpleblocks/imageblock.py b/peepingtom/datablocks/simpleblocks/imageblock.py
new file mode 100644
index 00000000..e8241c15
--- /dev/null
+++ b/peepingtom/datablocks/simpleblocks/imageblock.py
@@ -0,0 +1,31 @@
+import logging
+
+import numpy as np
+
+from ..abstractblocks import SpatialBlock, SimpleBlock
+from ...depictors import ImageDepictor
+
+
+logger = logging.getLogger(__name__)
+
+
+class ImageBlock(SpatialBlock, SimpleBlock):
+    """
+    Image block
+    """
+    _depiction_modes = {'default': ImageDepictor}
+
+    def _data_setter(self, data):
+        data = np.asarray(data)  # asarray does not copy unless needed
+        if data.ndim < 2:
+            raise ValueError('images must have at least 2 dimensions')
+        elif data.ndim > 3:
+            raise NotImplementedError('images with more than 3 dimensions are not yet implemented')
+        return data
+
+    @property
+    def shape(self):
+        return self.data.shape
+
+    def _ndim(self):
+        return self.data.ndim
diff --git a/peepingtom/core/datablocks/lineblock.py b/peepingtom/datablocks/simpleblocks/lineblock.py
similarity index 81%
rename from peepingtom/core/datablocks/lineblock.py
rename to peepingtom/datablocks/simpleblocks/lineblock.py
index 3b29de6c..d91d2bf4 100644
--- a/peepingtom/core/datablocks/lineblock.py
+++ b/peepingtom/datablocks/simpleblocks/lineblock.py
@@ -2,6 +2,7 @@
 from scipy.interpolate import splprep, splev
 
 from .pointblock import PointBlock
+from ...depictors import LineDepictor
 
 
 class LineBlock(PointBlock):
@@ -18,7 +19,9 @@ class LineBlock(PointBlock):
 
     Polarity (direction) of lines, lines start from 0 to n along the 0th dimension
     """
-    def __init__(self, data, spline_smoothing_parameter=0, **kwargs):
+    _depiction_modes = {'default': LineDepictor}
+
+    def __init__(self, *, spline_smoothing_parameter=0, **kwargs):
         """
 
         Parameters
@@ -28,7 +31,7 @@ def __init__(self, data, spline_smoothing_parameter=0, **kwargs):
         kwargs : kwargs are passed to SimpleBlock object
 
         """
-        super().__init__(data, **kwargs)
+        super().__init__(**kwargs)
 
         # initialise attributes related to spline fitting
         self.spline_smoothing_parameter = spline_smoothing_parameter
@@ -42,22 +45,17 @@ def spline_smoothing_parameter(self):
     def spline_smoothing_parameter(self, value):
         self._spline_smoothing_parameter = float(value)
 
-    def fit_spline(self, dimensions: str = 'xyz', smoothing_parameter=None):
+    def fit_spline(self, dimensions='xyz', smoothing_parameter=None):
         """
-
-        Parameters
-        ----------
         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_dimension(dimensions, as_type='tuple')
+        dims_to_fit = self._get_named_dimensions(dimensions).T
         self._tck, _ = splprep(dims_to_fit, s=self.spline_smoothing_parameter)
 
         return self._tck
@@ -73,8 +71,3 @@ def smooth_backbone(self):
     def _generate_smooth_backbone(self, n_points=1000):
         self.fit_spline()
         return self.evaluate_spline(n_points)
-
-    def dump(self):
-        kwargs = super().dump()
-        kwargs.update({'line': self.data})
-        return kwargs
diff --git a/peepingtom/datablocks/simpleblocks/orientationblock.py b/peepingtom/datablocks/simpleblocks/orientationblock.py
new file mode 100644
index 00000000..0d8f25b2
--- /dev/null
+++ b/peepingtom/datablocks/simpleblocks/orientationblock.py
@@ -0,0 +1,43 @@
+import numpy as np
+
+from ..abstractblocks import SpatialBlock, SimpleBlock
+
+
+class OrientationBlock(SpatialBlock, SimpleBlock):
+    """
+    OrientationBlock objects represent orientations in a 2d or 3d space
+
+    Contains factory methods for instantiation from eulerian angles
+
+    data : (n, 2, 2) or (n, 3, 3) array of rotation matrices R
+                        R should satisfy Rv = v' where v is a column vector
+
+    """
+    def _data_setter(self, data):
+        data = np.array(data)
+        # check for single matrix case and assert dimensionality
+        val_error = ValueError(f'rotation matrices should be of shape '
+                               f'(2, 2), (3, 3), (n, 2, 2) or (n, 3, 3), '
+                               f'not {data.shape}')
+        if data.ndim == 1:
+            raise val_error
+        if not data.shape[-1] == data.shape[-2]:
+            raise val_error
+        if not data.shape[-1] in (2, 3):
+            raise val_error
+
+        if data.ndim == 2:
+            m = data.shape[-1]
+            data = data.reshape((1, m, m))
+
+        return data
+
+    @property
+    def n(self):
+        return len(self.data)
+
+    def _ndim(self):
+        return self.data.shape[-1]
+
+    def __shape_repr__(self):
+        return f'({self.n}, {self.ndim})'
diff --git a/peepingtom/datablocks/simpleblocks/pointblock.py b/peepingtom/datablocks/simpleblocks/pointblock.py
new file mode 100644
index 00000000..e8161714
--- /dev/null
+++ b/peepingtom/datablocks/simpleblocks/pointblock.py
@@ -0,0 +1,98 @@
+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, m) representing n points in m dimensions
+
+    order of dimensions along m is:
+    2d : (x, y)
+    3d : (x. y, z)
+    nd : (..., x, y, z)
+    """
+    _depiction_modes = {'default': PointDepictor}
+
+    def _data_setter(self, data):
+        # cast as array
+        data = np.asarray(data)
+
+        # coerce single point to right dims
+        if data.ndim == 1 and data.size > 0:
+            data = data.reshape((1, len(data)))
+        if data.size == 0:
+            data = data.reshape((0, 3))
+
+        # check ndim of data
+        if not data.ndim == 2:
+            raise ValueError("data object should have ndim == 2")
+
+        return data
+
+    @property
+    def n(self):
+        return len(self.data)
+
+    def _ndim(self):
+        return self.data.shape[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 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.n}, {self.ndim})'
diff --git a/peepingtom/datablocks/simpleblocks/propertyblock.py b/peepingtom/datablocks/simpleblocks/propertyblock.py
new file mode 100644
index 00000000..f480b5c5
--- /dev/null
+++ b/peepingtom/datablocks/simpleblocks/propertyblock.py
@@ -0,0 +1,60 @@
+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'({len(self.data)}, {len(self.data.columns)})'
diff --git a/peepingtom/depictors/__init__.py b/peepingtom/depictors/__init__.py
new file mode 100644
index 00000000..37518a47
--- /dev/null
+++ b/peepingtom/depictors/__init__.py
@@ -0,0 +1,11 @@
+from .napari import (
+    ImageDepictor,
+    LineDepictor,
+    MeshDepictor,
+    ParticleDepictor,
+    PointDepictor,
+)
+
+from .pyqtgraph import (
+    PropertyPlotDepictor,
+)
diff --git a/peepingtom/depictors/depictor.py b/peepingtom/depictors/depictor.py
new file mode 100644
index 00000000..06bbb0ec
--- /dev/null
+++ b/peepingtom/depictors/depictor.py
@@ -0,0 +1,38 @@
+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/peepingtom/depictors/napari/__init__.py b/peepingtom/depictors/napari/__init__.py
new file mode 100644
index 00000000..cad9760d
--- /dev/null
+++ b/peepingtom/depictors/napari/__init__.py
@@ -0,0 +1,6 @@
+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/peepingtom/depictors/napari/imagedepictor.py b/peepingtom/depictors/napari/imagedepictor.py
new file mode 100644
index 00000000..fb1f1118
--- /dev/null
+++ b/peepingtom/depictors/napari/imagedepictor.py
@@ -0,0 +1,8 @@
+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/peepingtom/depictors/napari/linedepictor.py b/peepingtom/depictors/napari/linedepictor.py
new file mode 100644
index 00000000..1bac2507
--- /dev/null
+++ b/peepingtom/depictors/napari/linedepictor.py
@@ -0,0 +1,45 @@
+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.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/peepingtom/depictors/napari/meshdepictor.py b/peepingtom/depictors/napari/meshdepictor.py
new file mode 100644
index 00000000..81f02650
--- /dev/null
+++ b/peepingtom/depictors/napari/meshdepictor.py
@@ -0,0 +1,8 @@
+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/peepingtom/depictors/napari/naparidepictor.py b/peepingtom/depictors/napari/naparidepictor.py
new file mode 100644
index 00000000..1440c0a3
--- /dev/null
+++ b/peepingtom/depictors/napari/naparidepictor.py
@@ -0,0 +1,47 @@
+import numpy as np
+from napari.layers import Points, Image, Vectors, Shapes, Surface
+
+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):
+        layer = Image(image, name=name, scale=scale, **kwargs)
+        self._init_layer(layer)
+
+    def _make_points_layer(self, points, name, **kwargs):
+        layer = Points(points, name=name, n_dimensional=True, **kwargs)
+        self._init_layer(layer)
+
+    def _make_vectors_layer(self, vectors, name, **kwargs):
+        layer = Vectors(vectors, name=name, **kwargs)
+        self._init_layer(layer)
+
+    def _make_shapes_layer(self, shape, shape_type, name, **kwargs):
+        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):
+        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/peepingtom/depictors/napari/particledepictor.py b/peepingtom/depictors/napari/particledepictor.py
new file mode 100644
index 00000000..a6b1ed95
--- /dev/null
+++ b/peepingtom/depictors/napari/particledepictor.py
@@ -0,0 +1,100 @@
+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 get_positions(self):
+        return self.datablock.positions.zyx * self.rescale * self.datablock.pixel_size
+
+    def get_orientations_zyx(self):
+        return self.datablock.orientations.data[:, ::-1, ::-1]
+
+    def _unit_vector(self, axis):
+        idx = dim_names_to_indexes(axis, order='zyx')[0]
+        # initialise unit vector array
+        unit_vector = np.zeros(self.datablock.ndim, dtype=float)
+        unit_vector[idx] = 1
+        return unit_vector
+
+    def get_vectors(self):
+        pos = self.get_positions()
+        ori = self.get_orientations_zyx()
+        axes = 'zyx'[-self.datablock.ndim:]
+        all_vectors = {}
+        for ax in axes:
+            vectors = ori @ self._unit_vector(ax)
+            shifted_vectors = np.stack([pos, vectors], axis=1)
+            all_vectors[ax] = shifted_vectors
+        return all_vectors
+
+    def set_rescale(self):
+        pos = self.datablock.positions.data
+        if 0 <= pos.min().item() <= pos.max().item() <= 1:
+            peeper = self.datablock.peeper
+            if peeper:
+                same_volume = peeper.volumes[self.datablock.volume]
+                from ...datablocks import ImageBlock
+                for db in same_volume:
+                    if isinstance(db, ImageBlock):
+                        self.rescale = db.shape
+                        break
+
+    @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/peepingtom/depictors/napari/pointdepictor.py b/peepingtom/depictors/napari/pointdepictor.py
new file mode 100644
index 00000000..fe47cac3
--- /dev/null
+++ b/peepingtom/depictors/napari/pointdepictor.py
@@ -0,0 +1,14 @@
+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/peepingtom/depictors/pyqtgraph/__init__.py b/peepingtom/depictors/pyqtgraph/__init__.py
new file mode 100644
index 00000000..85ea6d29
--- /dev/null
+++ b/peepingtom/depictors/pyqtgraph/__init__.py
@@ -0,0 +1,2 @@
+from .plotdepictor import PlotDepictor
+from .propertyplotdepictor import PropertyPlotDepictor
diff --git a/peepingtom/depictors/pyqtgraph/plotdepictor.py b/peepingtom/depictors/pyqtgraph/plotdepictor.py
new file mode 100644
index 00000000..edef9f8c
--- /dev/null
+++ b/peepingtom/depictors/pyqtgraph/plotdepictor.py
@@ -0,0 +1,18 @@
+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/peepingtom/depictors/pyqtgraph/propertyplotdepictor.py b/peepingtom/depictors/pyqtgraph/propertyplotdepictor.py
new file mode 100644
index 00000000..9909e4a5
--- /dev/null
+++ b/peepingtom/depictors/pyqtgraph/propertyplotdepictor.py
@@ -0,0 +1,13 @@
+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/peepingtom/functions.py b/peepingtom/functions.py
new file mode 100644
index 00000000..701d1c54
--- /dev/null
+++ b/peepingtom/functions.py
@@ -0,0 +1,28 @@
+from pathlib import Path
+
+from .peeper import Peeper
+from .datablocks import DataBlock
+from .utils import listify, inherit_signature
+from .io_ import read
+
+
+@inherit_signature(read, Peeper, ignore_args=['globs', 'name', 'datablocks'], add_args={'name': None})
+def peep(*args, **kwargs):
+    """
+    Generate a Peeper from an input object or by reading from paths.
+    Additionally to all the arguments of `read`, this accepts Peepers 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 peep type "{type(obj)}"')
+    peeper = read(*globs, **kwargs)
+    peeper.extend(datablocks)
+    return peeper
diff --git a/peepingtom/gui/__init__.py b/peepingtom/gui/__init__.py
new file mode 100644
index 00000000..2069fefd
--- /dev/null
+++ b/peepingtom/gui/__init__.py
@@ -0,0 +1,1 @@
+from .viewer import Viewer
diff --git a/peepingtom/gui/viewer.py b/peepingtom/gui/viewer.py
new file mode 100644
index 00000000..99f2f9db
--- /dev/null
+++ b/peepingtom/gui/viewer.py
@@ -0,0 +1,163 @@
+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 Peeper functionality
+    """
+    def __init__(self, peeper):
+        self.peeper = peeper
+
+        self.napari_viewer = None
+        self.plots = None
+        self.pt_widget = None
+
+    def show(self, napari_viewer=None, **kwargs):
+        self.ensure_ready(napari_viewer=napari_viewer)
+        for db in self.peeper:
+            db.init_depictor(**kwargs)
+        if self.peeper.volumes:
+            self.show_volume(list(self.peeper.volumes.keys())[0])
+        if self.peeper.plots:
+            self.plots.show()
+
+    @property
+    def shown(self):
+        if self.napari_viewer and self.volume_selector:
+            return self.peeper.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_pt_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_pt_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 - PeepingTom')
+        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.peeper.purge_gui)
+
+    def _init_plots(self):
+        self.plots = GraphicsLayoutWidget()
+        self._plots_napari_widget = self.napari_viewer.window.add_dock_widget(self.plots,
+                                                                              name='PeepingTom - 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_pt_widget(self):
+        self.pt_widget = QWidget()
+        layout = QVBoxLayout()
+        self.pt_widget.setLayout(layout)
+
+        self.volume_selector = QComboBox(self.pt_widget)
+        self.volume_selector.addItems(self.peeper.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._pt_napari_widget = self.napari_viewer.window.add_dock_widget(self.pt_widget,
+                                                                           name='PeepingTom',
+                                                                           area='left')
+        # use napari hide and show methods
+        self.pt_widget.show = self._pt_napari_widget.show
+        self.pt_widget.hide = self._pt_napari_widget.hide
+
+    def _hook_keybindings(self):
+        self.napari_viewer.bind_key('PageUp', self.previous_volume)
+        self.napari_viewer.bind_key('PageDown', self.next_volume)
+
+    def update_pt_widget(self):
+        if self.pt_widget is not None:
+            current_text = self.volume_selector.currentText()
+            with block_signals(self.volume_selector):
+                self.volume_selector.clear()
+                self.volume_selector.addItems(self.peeper.volumes.keys())
+                self.volume_selector.setCurrentText(current_text)
+        self.show()
+
+    def clear_shown(self):
+        for layer in self.napari_viewer.layers.copy():
+            if layer in self.peeper.napari_layers:
+                self.napari_viewer.layers.remove(layer)
+        self.plots.clear()
+
+    def show_volume(self, volume):
+        if volume is None:
+            return
+        self.volume_selector.setCurrentText(volume)
+        datablocks = self.peeper.omni + self.peeper.volumes[volume]
+        ndim = 2
+        for db in datablocks:
+            ndim = max(ndim, getattr(db, 'ndim', 2))
+        self.napari_viewer.dims.ndisplay = ndim
+
+        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/peepingtom/io_/__init__.py b/peepingtom/io_/__init__.py
index 52d059b5..2afb3694 100644
--- a/peepingtom/io_/__init__.py
+++ b/peepingtom/io_/__init__.py
@@ -1,2 +1,2 @@
-from .read import read
-from .write import write
+from .reading import read, find_files
+from .writing import write
diff --git a/peepingtom/io_/read/__init__.py b/peepingtom/io_/read/__init__.py
deleted file mode 100644
index cda44587..00000000
--- a/peepingtom/io_/read/__init__.py
+++ /dev/null
@@ -1,1 +0,0 @@
-from .read import read
diff --git a/peepingtom/io_/read/em/__init__.py b/peepingtom/io_/read/em/__init__.py
deleted file mode 100644
index 138bb487..00000000
--- a/peepingtom/io_/read/em/__init__.py
+++ /dev/null
@@ -1,1 +0,0 @@
-from .em import read_em
diff --git a/peepingtom/io_/read/em/em.py b/peepingtom/io_/read/em/em.py
deleted file mode 100644
index c2dc0b10..00000000
--- a/peepingtom/io_/read/em/em.py
+++ /dev/null
@@ -1,12 +0,0 @@
-import emfile
-
-from ....core import ImageBlock
-from ...utils import guess_name
-
-
-def read_em(image_path, name_regex=None, **kwargs):
-    """
-    read an em file and return an ImageBlock
-    """
-    name = guess_name(image_path, name_regex)
-    return ImageBlock(emfile.read(image_path)[1], name=name)
diff --git a/peepingtom/io_/read/mrc/__init__.py b/peepingtom/io_/read/mrc/__init__.py
deleted file mode 100644
index 4fa9c0ae..00000000
--- a/peepingtom/io_/read/mrc/__init__.py
+++ /dev/null
@@ -1,1 +0,0 @@
-from .mrc import read_mrc
diff --git a/peepingtom/io_/read/mrc/mrc.py b/peepingtom/io_/read/mrc/mrc.py
deleted file mode 100644
index 00c843b6..00000000
--- a/peepingtom/io_/read/mrc/mrc.py
+++ /dev/null
@@ -1,12 +0,0 @@
-import mrcfile
-
-from ....core import ImageBlock
-from ...utils import guess_name
-
-
-def read_mrc(image_path, name_regex=None, **kwargs):
-    """
-    read an mrc file and return an ImageBlock
-    """
-    name = guess_name(image_path, name_regex)
-    return ImageBlock(mrcfile.open(image_path).data, name=name)
diff --git a/peepingtom/io_/read/read.py b/peepingtom/io_/read/read.py
deleted file mode 100644
index 300c3de1..00000000
--- a/peepingtom/io_/read/read.py
+++ /dev/null
@@ -1,133 +0,0 @@
-import re
-from collections import defaultdict
-from itertools import zip_longest
-
-from ..utils import _path, listify
-from .star import read_star
-from .mrc import read_mrc
-from .em import read_em
-
-from ...core import DataCrate
-from ...utils import AttributedList
-
-
-# a mapping of file extensions to readers, tuple map to tuples:
-#   - 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',): (read_mrc,),
-    ('.em',): (read_em,),
-}
-
-
-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))
-                    return datablocks
-                except ValueError:
-                    # this will be raised by individual readers when the file can't be read.
-                    # Keep trying until all options are exhausted
-                    continue
-    raise ValueError(f'could not read {file_path}')
-
-
-def find_files(paths, filters=None, recursive=False):
-    """
-    take a path or iterable thereof and find all the contained readable files
-    filters: a regex-like strings or iterable thereof used to match filenames
-    """
-    # sanitize input
-    paths = listify(paths)
-    paths = [_path(path) for path in paths]
-    filters = listify(filters)
-
-    # extract files
-    files = []
-    for path in paths:
-        if not path.exists():
-            raise FileNotFoundError(f'{path} does not exist')
-
-        # find all the readable files, if any
-        if path.is_file():
-            files.append(path)
-        elif path.is_dir():
-            basedir = '.'
-            if recursive:
-                basedir = '**'
-            known_formats = [ext for formats in readers for ext in formats]
-            for ext in known_formats:
-                files.extend(file for file in path.glob(f'{basedir}/*{ext}'))
-        if not files:
-            raise FileNotFoundError(f'{path} does not contain any files of a know type')
-
-    # filter files if requested
-    files = [file for file in files if all(re.search(regex, str(file)) for regex in filters)]
-
-    return files
-
-
-def read_to_datablocks(paths, filters=None, recursive=False, strict=False, **kwargs):
-    """
-    read generic path(s) into the appropriate datablocks
-    strict: if set to False, ignore failures and read what possible
-    """
-    datablocks = []
-    for file in find_files(paths, filters=filters, recursive=recursive):
-        try:
-            datablocks.extend(read_file(file, **kwargs))
-        except ValueError:
-            if strict:
-                raise
-    if not datablocks:
-        raise ValueError(f'could not read any data from {paths}')
-    return datablocks
-
-
-def read(paths, mode=None, **kwargs):
-    """
-    read generic path(s) and construct datacrates accordingly
-    mode:
-        - lone: each datablock in a separate crate
-        - zip_by_type: crates with one of each datablock type
-        - bunch: all datablocks in a single crate
-    """
-    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 = read_to_datablocks(paths, **kwargs)
-    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':
-        crates = [DataCrate(db) for db in datablocks]
-    elif mode == 'bunch':
-        crates = [DataCrate(datablocks)]
-    elif mode == 'zip_by_type':
-        crates = []
-        for dbs in zip_longest(datablocks_by_type.values()):
-            crates.append(DataCrate(dbs))
-        # TODO: add rescaling?
-
-    return AttributedList(crates)
diff --git a/peepingtom/io_/read/star/__init__.py b/peepingtom/io_/read/star/__init__.py
deleted file mode 100644
index 1f7bc8b8..00000000
--- a/peepingtom/io_/read/star/__init__.py
+++ /dev/null
@@ -1,1 +0,0 @@
-from .star import read_star
diff --git a/peepingtom/io_/read/star/star.py b/peepingtom/io_/read/star/star.py
deleted file mode 100644
index 7be42b31..00000000
--- a/peepingtom/io_/read/star/star.py
+++ /dev/null
@@ -1,82 +0,0 @@
-import starfile
-import eulerangles
-
-from ....core import ParticleBlock
-from ...utils import star_types, guess_name
-
-
-def split_and_name(dataframe, split_by, basename, name_regex=None):
-    """
-    splits a dataframe from a starfile in separate
-    """
-    if split_by in dataframe.columns:
-        groups = dataframe.groupby('rlnMicrographName')
-        return [(guess_name(subname, name_regex), dataframe) for subname, dataframe in groups]
-    else:
-        return [(guess_name(basename, name_regex), dataframe)]
-
-
-def euler2matrix(euler_angles, convention):
-    """
-    convert euler angles to matrices
-    """
-    rotation_matrices = eulerangles.euler2matrix(euler_angles,
-                                                 axes=convention['axes'],
-                                                 intrinsic=convention['intrinsic'],
-                                                 positive_ccw=convention['positive_ccw'])
-
-    # If rotation matrices represent rotations of particle onto reference
-    # invert them so that we return matrices which transform reference onto particle
-    if convention['rotate_reference'] is False:
-        rotation_matrices = rotation_matrices.transpose((0, 2, 1))
-    return rotation_matrices
-
-
-def read_star(star_path, data_columns=[], name_regex=None, **kwargs):
-    """
-    read a starfile into one or multiple ParticleBlocks
-    data_columns: a list of column names to include as properties
-    name_regex: a regex pattern to use to guess the name of individual datasets
-    """
-    raw_dfs = starfile.read((star_path), always_dict=True)
-
-    # metadata = {}
-    dataframes = []
-    # check if relion3.1 format
-    if list(raw_dfs.keys()) == ['optics', 'particles']:
-        # TODO: use optics metadata
-        # metadata = raw_dfs['optics']
-        dataframes.append(raw_dfs['particles'])
-    else:
-        # assume every key is a different dataset (older version)
-        # TODO: make more specific
-        dataframes.extend(list(raw_dfs.values()))
-
-    # create particles
-    datablocks = []
-    for dataframe in dataframes:
-        # try every type of starfile convention
-        for star_type, params in star_types.items():
-            # check if the necessary columns exist
-            if all(colname in dataframe.columns for colname in params['coords']):
-                # split and guess names
-                named_dfs = split_and_name(dataframe, split_by=params['split_by'],
-                                           basename=star_path, name_regex=name_regex)
-                for name, df in named_dfs:
-                    # extract the data and convert it to peepingtom format
-                    raw_positions = df[params['coords']]
-                    raw_shifts = df.get(params['shifts'], 0)
-                    raw_eulers = df[params['angles']]
-                    raw_properties = df[data_columns]
-
-                    positions = (raw_positions + raw_shifts).to_numpy()
-                    orientations = euler2matrix(raw_eulers, params['angle_convention'])
-                    properties = {key: df[key].to_numpy() for key in raw_properties}
-
-                    datablocks.append(ParticleBlock(positions, orientations, properties, name=name))
-                # break of out loop if something was found
-                break
-        else:
-            raise ValueError(f'dataframe with columns {list(dataframe.columns)} could not be read')
-
-    return datablocks
diff --git a/peepingtom/io_/reading/__init__.py b/peepingtom/io_/reading/__init__.py
new file mode 100644
index 00000000..258c3ee9
--- /dev/null
+++ b/peepingtom/io_/reading/__init__.py
@@ -0,0 +1,1 @@
+from .main import read, find_files
diff --git a/peepingtom/io_/reading/box.py b/peepingtom/io_/reading/box.py
new file mode 100644
index 00000000..c4c5188d
--- /dev/null
+++ b/peepingtom/io_/reading/box.py
@@ -0,0 +1,11 @@
+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/peepingtom/io_/reading/cbox.py b/peepingtom/io_/reading/cbox.py
new file mode 100644
index 00000000..094272a2
--- /dev/null
+++ b/peepingtom/io_/reading/cbox.py
@@ -0,0 +1,13 @@
+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/peepingtom/io_/reading/em.py b/peepingtom/io_/reading/em.py
new file mode 100644
index 00000000..8cc4f336
--- /dev/null
+++ b/peepingtom/io_/reading/em.py
@@ -0,0 +1,21 @@
+import emfile
+
+from ...datablocks import ImageBlock
+from ..utils import guess_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)
+
+    def loader(imageblock):
+        header, data = emfile.read(image_path)
+        imageblock.data = data
+        imageblock.pixel_size = header['OBJ']
+
+    ib = ImageBlock(lazy_loader=loader, name=name)
+    if not lazy:
+        ib.load()
+    return ib
diff --git a/peepingtom/io_/reading/main.py b/peepingtom/io_/reading/main.py
new file mode 100644
index 00000000..22949d1a
--- /dev/null
+++ b/peepingtom/io_/reading/main.py
@@ -0,0 +1,160 @@
+from collections import defaultdict
+from itertools import zip_longest
+from pathlib import Path
+import logging
+
+from ..utils import ParseError
+from ...utils import listify
+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 ...peeper import Peeper
+
+
+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(*globs,
+         name='Peeper',
+         mode=None,
+         name_regex=None,
+         pixel_size=None,
+         strict=False,
+         mmap=False,
+         lazy=True,
+         **kwargs):
+    r"""
+    Read any number of paths or glob patterns and construct a Peeper accordingly.
+
+    Peeper and Datablock construction arguments:
+        name: the name of the Peeper (default: Peeper)
+        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 PeepingTom
+    Performance arguments:
+        mmap: open file in memory map mode (if possible)
+        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,
+                                        mmap=mmap, 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 Peeper(datablocks, name=name)
diff --git a/peepingtom/io_/reading/mrc.py b/peepingtom/io_/reading/mrc.py
new file mode 100644
index 00000000..fbd879de
--- /dev/null
+++ b/peepingtom/io_/reading/mrc.py
@@ -0,0 +1,32 @@
+import logging
+
+import mrcfile
+
+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, mmap=False, lazy=True, **kwargs):
+    """
+    read an mrc file and return an ImageBlock
+    """
+    name = guess_name(image_path, name_regex)
+
+    def loader(imageblock):
+        if mmap is True:
+            mrc = mrcfile.mmap(image_path)
+        else:
+            mrc = mrcfile.open(image_path)
+        imageblock.data = mrc.data
+        pixel_size = structured_to_unstructured(mrc.voxel_size)[::-1]
+        imageblock.pixel_size = pixel_size
+
+    ib = ImageBlock(lazy_loader=loader, name=name)
+    if not lazy:
+        ib.load()
+    logger.debug(f'succesfully read "{image_path}", {lazy=}, {mmap=}')
+    return ib
diff --git a/peepingtom/io_/reading/star.py b/peepingtom/io_/reading/star.py
new file mode 100644
index 00000000..4a493ed3
--- /dev/null
+++ b/peepingtom/io_/reading/star.py
@@ -0,0 +1,130 @@
+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/peepingtom/io_/reading/tbl.py b/peepingtom/io_/reading/tbl.py
new file mode 100644
index 00000000..b1892cac
--- /dev/null
+++ b/peepingtom/io_/reading/tbl.py
@@ -0,0 +1,76 @@
+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/peepingtom/io_/utils/__init__.py b/peepingtom/io_/utils/__init__.py
index 1b41d8c4..25b7fd6b 100644
--- a/peepingtom/io_/utils/__init__.py
+++ b/peepingtom/io_/utils/__init__.py
@@ -1,2 +1,2 @@
-from .generic import _path, guess_name, listify
-from .star_conventions import star_types
+from .generic import guess_name, ParseError
+from .angle_conversion import rotangle2matrix
diff --git a/peepingtom/io_/utils/angle_conversion.py b/peepingtom/io_/utils/angle_conversion.py
new file mode 100644
index 00000000..b8598af8
--- /dev/null
+++ b/peepingtom/io_/utils/angle_conversion.py
@@ -0,0 +1,13 @@
+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/peepingtom/io_/utils/generic.py b/peepingtom/io_/utils/generic.py
index ab3a2aef..06a78026 100644
--- a/peepingtom/io_/utils/generic.py
+++ b/peepingtom/io_/utils/generic.py
@@ -1,18 +1,14 @@
 import re
-from pathlib import Path
-from collections.abc import Iterable
 
 
-def _path(path):
-    """
-    clean up a path
-    """
-    return Path(path).expanduser().resolve()
+class ParseError(RuntimeError):
+    pass
 
 
 # a list of commonly used base names for starfiles in regex form
 common_name_regexes = (
-    'TS_\d+',
+    r'TS_\d+',
+    r'\d+',
 )
 
 
@@ -29,14 +25,3 @@ def guess_name(string, name_regex=None):
             return match.group(0)
     else:
         return None
-
-
-def listify(obj):
-    """
-    transform input into an appropriate list, unless already list-like
-    """
-    if isinstance(obj, (list, tuple)):
-        return obj
-    if obj is None:
-        return []
-    return [obj]
diff --git a/peepingtom/io_/utils/mrc_headers.py b/peepingtom/io_/utils/mrc_headers.py
deleted file mode 100644
index 0d1d8c8a..00000000
--- a/peepingtom/io_/utils/mrc_headers.py
+++ /dev/null
@@ -1,45 +0,0 @@
-from pathlib import Path
-from typing import Union, List
-
-import mrcfile
-
-
-def data_from_header(*files: Union[List[str, Path], str, Path], attributes: List[str]):
-    """
-
-    Parameters
-    ----------
-    files
-    attributes
-
-    Returns dict of format {filename : attributes} for each mrcfile
-    -------
-
-    """
-    data = {}
-    files = [files] if isinstance(files, (str, Path)) else files
-
-    for file in files:
-        with mrcfile.open(file, header_only=True, permissive=True) as mrc:
-            _attributes = {attribute: getattr(mrc.header, attribute) for attribute in attributes}
-            data[str(file)] = _attributes
-    return data
-
-
-def nx(*files: Union[List[str, Path], str, Path]):
-    data = data_from_header(*files, attributes=['nx'])
-    return {name: data[name]['nx'] for name in data.keys()}
-
-
-def ny(*files: Union[List[str, Path], str, Path]):
-    data = data_from_header(*files, attributes=['ny'])
-    return {name: data[name['ny']] for name in data.keys()}
-
-
-def nz(*files: Union[List[str, Path], str, Path]):
-    data = data_from_header(*files, attributes=['nz'])
-    return {name: data[name]['nz'] for name in data.keys()}
-
-
-def nxnynz(*files: Union[List[str, Path], str, Path]):
-    return data_from_header(*files, attributes=['nx', 'ny', 'nz'])
diff --git a/peepingtom/io_/utils/star_conventions.py b/peepingtom/io_/utils/star_conventions.py
deleted file mode 100644
index de726c0b..00000000
--- a/peepingtom/io_/utils/star_conventions.py
+++ /dev/null
@@ -1,26 +0,0 @@
-star_types = {
-    'dynamo': {
-        'coords': ['x', 'y', 'z'],
-        'shifts': ['dx', 'dy', 'dz'],
-        'angles': ['tdrot', 'tilt', 'narot'],
-        'angle_convention': {'axes': 'zxz',
-                             'intrinsic': False,
-                             'positive_ccw': True,
-                             'rotate_reference': False},
-        'split_by': 'rlnMicrographName',
-    },
-    'relion': {
-        'coords': [f'rlnCoordinate{axis}' for axis in 'XYZ'],
-        'shifts': [f'rlnOrigin{axis}' for axis in 'XYZ'],
-        'angles': [f'rlnAngle{angle}' for angle in ('Rot', 'Tilt', 'Psi')],
-        'angle_convention': {'axes': 'zyz',
-                             'intrinsic': True,
-                             'positive_ccw': True,
-                             'rotate_reference': False},
-        'split_by': 'rlnMicrographName',
-    },
-    'relion_2d': {
-        'coords': [f'rlnCoordinate{axis}' for axis in 'XY'],
-        'shifts': [f'rlnOrigin{axis}' for axis in 'XY'],
-    },
-}
diff --git a/peepingtom/io_/write/em/em.py b/peepingtom/io_/write/em/em.py
deleted file mode 100644
index 65983eca..00000000
--- a/peepingtom/io_/write/em/em.py
+++ /dev/null
@@ -1,13 +0,0 @@
-import emfile
-
-from ...utils import _path
-
-
-def write_em(imageblock, file_path, overwrite=False):
-    """
-    write an image block to disk as an .em file
-    """
-    path = str(_path(file_path))
-    if not path.endswith('.em'):
-        path = f'{path}.em'
-    emfile.write(path, imageblock.data, header_params={}, overwrite=overwrite)
diff --git a/peepingtom/io_/write/mrc/mrc.py b/peepingtom/io_/write/mrc/mrc.py
deleted file mode 100644
index 85923599..00000000
--- a/peepingtom/io_/write/mrc/mrc.py
+++ /dev/null
@@ -1,13 +0,0 @@
-import mrcfile
-
-from ...utils import _path
-
-
-def write_mrc(imageblock, file_path, overwrite=False):
-    """
-    write an image block to disk as an .mrc file
-    """
-    path = str(_path(file_path))
-    if not path.endswith('.mrc'):
-        path = f'{path}.mrc'
-    mrcfile.new(path, imageblock.data, overwrite=overwrite)
diff --git a/peepingtom/io_/write/star/star.py b/peepingtom/io_/write/star/star.py
deleted file mode 100644
index cd7ec184..00000000
--- a/peepingtom/io_/write/star/star.py
+++ /dev/null
@@ -1,45 +0,0 @@
-import pandas as pd
-import starfile
-import eulerangles
-
-from ...utils import _path, star_types
-
-
-def matrix2euler(rotation_matrices, convention):
-    """
-    convert euler matrices to angles
-    """
-    # If rotation matrices represent rotations of particle onto reference
-    # invert them so that we return matrices which transform reference onto particle
-    if convention['rotate_reference'] is False:
-        rotation_matrices = rotation_matrices.transpose((0, 2, 1))
-
-    euler_angles = eulerangles.matrix2euler(rotation_matrices,
-                                            target_axes=convention['axes'],
-                                            target_intrinsic=convention['intrinsic'],
-                                            target_positive_ccw=convention['positive_ccw'])
-    return euler_angles
-
-
-def write_star(particleblock, file_path, star_type='relion', data_colums=[], overwrite=False):
-    """
-    write a particle block to disk as a .star file
-    star_type: one of the supported starfile types
-    data_colums: particleblock proerties to include as columns
-    """
-    coords = particleblock.positions.data
-    orientation_matrices = particleblock.orientations.data
-    eulers = matrix2euler(orientation_matrices, star_types[star_type]['angle_convention'])
-    properties = [values for name, values in particleblock.properties.items() if name in data_colums]
-
-    df = pd.DataFrame()
-    column_names = star_types[star_type]['coords'] + star_types[star_type]['angles'] + data_colums
-    x, y, z = coords.T
-    ang1, ang2, ang3 = eulers.T
-    for name, values in zip(column_names, [x, y, z, ang1, ang2, ang3] + properties):
-        df[name] = values
-
-    path = str(_path(file_path))
-    if not path.endswith('.star'):
-        path = f'{path}.star'
-    starfile.write(df, path, overwrite=overwrite)
diff --git a/peepingtom/io_/write/__init__.py b/peepingtom/io_/writing/__init__.py
similarity index 100%
rename from peepingtom/io_/write/__init__.py
rename to peepingtom/io_/writing/__init__.py
diff --git a/peepingtom/io_/write/em/__init__.py b/peepingtom/io_/writing/em/__init__.py
similarity index 100%
rename from peepingtom/io_/write/em/__init__.py
rename to peepingtom/io_/writing/em/__init__.py
diff --git a/peepingtom/io_/writing/em/em.py b/peepingtom/io_/writing/em/em.py
new file mode 100644
index 00000000..a357baa4
--- /dev/null
+++ b/peepingtom/io_/writing/em/em.py
@@ -0,0 +1,10 @@
+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/peepingtom/io_/write/mrc/__init__.py b/peepingtom/io_/writing/mrc/__init__.py
similarity index 100%
rename from peepingtom/io_/write/mrc/__init__.py
rename to peepingtom/io_/writing/mrc/__init__.py
diff --git a/peepingtom/io_/writing/mrc/mrc.py b/peepingtom/io_/writing/mrc/mrc.py
new file mode 100644
index 00000000..b35750a9
--- /dev/null
+++ b/peepingtom/io_/writing/mrc/mrc.py
@@ -0,0 +1,10 @@
+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/peepingtom/io_/write/star/__init__.py b/peepingtom/io_/writing/star/__init__.py
similarity index 100%
rename from peepingtom/io_/write/star/__init__.py
rename to peepingtom/io_/writing/star/__init__.py
diff --git a/peepingtom/io_/writing/star/star.py b/peepingtom/io_/writing/star/star.py
new file mode 100644
index 00000000..8c6ef350
--- /dev/null
+++ b/peepingtom/io_/writing/star/star.py
@@ -0,0 +1,68 @@
+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 = particleblock.ndim
+    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/peepingtom/io_/write/write.py b/peepingtom/io_/writing/write.py
similarity index 91%
rename from peepingtom/io_/write/write.py
rename to peepingtom/io_/writing/write.py
index 4c0fe893..2ccde32d 100644
--- a/peepingtom/io_/write/write.py
+++ b/peepingtom/io_/writing/write.py
@@ -1,8 +1,9 @@
 from collections import Counter
+from pathlib import Path
 
-from ...core import ImageBlock, ParticleBlock
+from ...datablocks import ImageBlock, ParticleBlock
 
-from ..utils import _path, listify
+from ...utils import listify
 from .mrc import write_mrc
 from .em import write_em
 from .star import write_star
@@ -28,7 +29,7 @@ def write(datablocks, paths, overwrite=False, strict=False, **kwargs):
     """
     # sanitize input
     datablocks = listify(datablocks)
-    paths = [_path(path) for path in listify(paths)]
+    paths = [Path(path).expanduser().resolve() for path in listify(paths)]
 
     # check if numbers match
     if len(datablocks) != len(paths) and len(datablocks):
@@ -53,7 +54,6 @@ def write(datablocks, paths, overwrite=False, strict=False, **kwargs):
                     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!')
diff --git a/peepingtom/peeper.py b/peepingtom/peeper.py
new file mode 100644
index 00000000..0d7b8477
--- /dev/null
+++ b/peepingtom/peeper.py
@@ -0,0 +1,310 @@
+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 Peeper:
+    """
+    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('PT_OMNI', None)
+        return volumes
+
+    @property
+    def omni(self):
+        return self._nested().get('PT_OMNI', DispatchList())
+
+    @property
+    def ndim(self):
+        ndims = [getattr(db, 'ndim', 0) for db in self]
+        return max(ndims)
+
+    def is_view(self):
+        return self.view_of is not self
+
+    def _sanitize(self, iterable, deduplicate=True):
+        listified = listify(iterable)
+        for item in listified:
+            if not isinstance(item, DataBlock):
+                raise TypeError(f'Peeper 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:
+                # TODO: this is broken. Needs to be fixed, if possible.
+                # if db.peeper is not None:
+                    # raise RuntimeError('Datablocks cannot be assigned to a new Peeper.')
+                db.peeper = 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 Peeper(*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 peeper, 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_pt_widget()
+
+    def extend(self, items):
+        if self.is_view():
+            raise TypeError('Peeper view is immutable')
+        self._extend(items)
+
+    def remove(self, item):
+        self._data.remove(item)
+
+    # TODO: adding peeper needs some more work. Name clashing and similar issues need to be solved.
+    # def __add__(self, other):
+        # if isinstance(other, Peeper):
+            # return Peeper(self._data + self._sanitize(other))
+        # 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/peepingtom/peeper/__init__.py b/peepingtom/peeper/__init__.py
deleted file mode 100644
index 0ad74dbf..00000000
--- a/peepingtom/peeper/__init__.py
+++ /dev/null
@@ -1,2 +0,0 @@
-from .depictors import Depictor, ImageDepictor, ParticleDepictor, LineDepictor, PointDepictor
-from .peeper import Peeper, peep
diff --git a/peepingtom/peeper/depictors/__init__.py b/peepingtom/peeper/depictors/__init__.py
deleted file mode 100644
index 24abe748..00000000
--- a/peepingtom/peeper/depictors/__init__.py
+++ /dev/null
@@ -1,5 +0,0 @@
-from .base import Depictor
-from .images import ImageDepictor
-from .lines import LineDepictor
-from .particles import ParticleDepictor
-from .points import PointDepictor
diff --git a/peepingtom/peeper/depictors/base.py b/peepingtom/peeper/depictors/base.py
deleted file mode 100644
index ff953e99..00000000
--- a/peepingtom/peeper/depictors/base.py
+++ /dev/null
@@ -1,96 +0,0 @@
-"""
-Depictor interfaces data classes to napari
-"""
-
-from napari.layers import Points, Image, Vectors, Shapes
-
-from ...core import MultiBlock
-
-
-class Depictor:
-    """
-    Depictors are SimpleBlock or MultiBlock wrappers controlling depiction of their contents
-    """
-    def __init__(self, datablock, peeper=None):
-        self.datablock = datablock
-
-        # hook self to the datablock
-        self.datablock.depictor = self
-        if isinstance(self.datablock, MultiBlock):
-            for block in self.datablock.blocks:
-                block.depictor = self
-
-        self.name = self.datablock.name
-        self.peeper = peeper
-
-        self.layers = []
-
-    @property
-    def viewer(self):
-        return self.peeper.viewer
-
-    def make_image_layer(self, image, name, **kwargs):
-        return Image(image, name=name, **kwargs)
-
-    def make_points_layer(self, points, name, **kwargs):
-        return  Points(points, name=name, **kwargs)
-
-    def make_vectors_layer(self, vectors, name, **kwargs):
-        return Vectors(vectors, name=name, **kwargs)
-
-    def make_shapes_layer(self, shape, shape_type, name, **kwargs):
-        return Shapes(shape, shape_type=shape_type, name=name, **kwargs)
-
-    def init_layers(self):
-        """
-        generate the appropriate napari layers and store them
-        """
-        raise NotImplementedError
-
-    def connect_layers(self):
-        """
-        connects events on layer data to an update of the relative datablock
-        """
-        for layer in self.layers:
-            layer.events.data.connect(self.push_changes)
-
-    def draw(self, viewer=None, remake_layers=False):
-        """
-        creates a new napari viewer if not present
-        displays the contents of the datablock
-        """
-        # create a new viewer if necessary
-        if viewer is None:
-            viewer = self.viewer
-        if remake_layers or not self.layers:
-            self.init_layers()
-            self.connect_layers()
-        for layer in self.layers:
-            self.viewer.add_layer(layer)
-
-    def hide(self, layers=None, delete_layers=False):
-        """
-        layer_id can be the index or name of a layer or a list of ids
-        """
-        if layers is None:
-            layers = [layer for layer in self.layers]
-        if not isinstance(layers, list):
-            layers = [layers]
-        for layer in layers:
-            if layer in self.layers:
-                self.viewer.layers.remove(layer)
-                if delete_layers:
-                    self.layers.remove(layer)
-
-    def update(self):
-        """
-        update the displayed data based on the current state of the datablock
-        """
-
-    def push_changes(self, event):
-        """
-        push changes in the local layers to the corresponding datablock
-        """
-
-    def __repr__(self):
-        return f'<{type(self).__name__}[{self.name}]{self.datablock.__shape_repr__()}>'
diff --git a/peepingtom/peeper/depictors/images.py b/peepingtom/peeper/depictors/images.py
deleted file mode 100644
index e3e04ff9..00000000
--- a/peepingtom/peeper/depictors/images.py
+++ /dev/null
@@ -1,12 +0,0 @@
-from .base import Depictor
-
-
-class ImageDepictor(Depictor):
-    def init_layers(self, image_kwargs={}, **kwargs):
-        ikwargs = {}
-        ikwargs.update(image_kwargs)
-
-        layer = self.make_image_layer(self.datablock.data,
-                                      name=f'{self.name} - image',
-                                      **ikwargs)
-        self.layers.append(layer)
diff --git a/peepingtom/peeper/depictors/lines.py b/peepingtom/peeper/depictors/lines.py
deleted file mode 100644
index fded5364..00000000
--- a/peepingtom/peeper/depictors/lines.py
+++ /dev/null
@@ -1,52 +0,0 @@
-from .base import Depictor
-
-
-class LineDepictor(Depictor):
-    def init_layers(self, point_kwargs={}, backbone_kwargs={}):
-        # default keyword arguments
-        pkwargs = {'size': 3,
-                   'face_color': 'cornflowerblue'}
-        bkwargs = {'edge_color': 'orangered',
-                   'edge_width': 1}
-
-        # update keyword arguments from passed dictionaries
-        pkwargs.update(point_kwargs)
-        bkwargs.update(backbone_kwargs)
-
-        # 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
-        points = self.make_points_layer(points_data,
-                                        name=f'{self.name} - points',
-                                        **pkwargs)
-
-        self.layers.append(points)
-
-        # draw line as path in napari
-        backbone = self.make_shapes_layer(backbone_data,
-                                          shape_type='path',
-                                          name=f'{self.name} - line',
-                                          **bkwargs)
-
-        self.layers.append(backbone)
-
-    @property
-    def points_layer(self):
-        return self.layers[0]
-
-    @property
-    def backbone_layer(self):
-        return self.layers[1]
-
-    def update(self):
-        backbone_data = self.datablock.smooth_backbone[:, ::-1]
-        self.backbone_layer.selected_data = {0}
-        self.backbone_layer.remove_selected()
-        self.backbone_layer.add(backbone_data, shape_type='path')
-
-    def push_changes(self, event):
-        self.datablock.data = self.points_layer.data[:, ::-1]
diff --git a/peepingtom/peeper/depictors/particles.py b/peepingtom/peeper/depictors/particles.py
deleted file mode 100644
index 7169a92d..00000000
--- a/peepingtom/peeper/depictors/particles.py
+++ /dev/null
@@ -1,51 +0,0 @@
-import numpy as np
-
-from .base import Depictor
-
-
-class ParticleDepictor(Depictor):
-    def init_layers(self, point_kwargs={}, vector_kwargs={}):
-        pkwargs = {'size': 3}
-        vkwargs = {'length': 10}
-
-        pkwargs.update(point_kwargs)
-        vkwargs.update(vector_kwargs)
-
-        p_layer = self.make_points_layer(self.datablock.positions.as_zyx(),
-                                         name=f'{self.name} - particle positions',
-                                         properties=self.datablock.properties.data,
-                                         **pkwargs)
-        self.layers.append(p_layer)
-
-        # get positions and 'projection' vectors
-        positions = self.datablock.positions.as_zyx()
-        unit_z_rotated_order_xyz = self.datablock.orientations.oriented_vectors('z').reshape((-1, 3))
-        unit_z_rotated_order_zyx = unit_z_rotated_order_xyz[:, ::-1]
-        # attach appropriate higher dimensions indeces to vectors
-        # TODO: make more general
-        if self.datablock.positions.ndim > 3:
-            padded = np.zeros_like(self.datablock.positions.data)
-            padded[:, -3:] = unit_z_rotated_order_zyx
-            unit_z_rotated_order_zyx = padded
-
-        napari_vectors = np.stack([positions, unit_z_rotated_order_zyx], axis=1)
-        v_layer = self.make_vectors_layer(napari_vectors,
-                                          name=f'{self.name} - particle orientations',
-                                          **vkwargs)
-        self.layers.append(v_layer)
-
-    @property
-    def point_layer(self):
-        return self.layers[0]
-
-    @property
-    def vector_layer(self):
-        return self.layers[1]
-
-    def update(self):
-        try:
-            self.point_layer.properties = {k: v for k, v in self.datablock.properties.data.items()
-                                           if len(v) == len(self.datablock.positions.data)}
-        except KeyError:
-            # happens if layers do not exist: just pass
-            pass
diff --git a/peepingtom/peeper/depictors/points.py b/peepingtom/peeper/depictors/points.py
deleted file mode 100644
index 77937305..00000000
--- a/peepingtom/peeper/depictors/points.py
+++ /dev/null
@@ -1,18 +0,0 @@
-from .base import Depictor
-
-
-class PointDepictor(Depictor):
-    def init_layers(self, point_kwargs={}, vector_kwargs={}):
-        pkwargs = {'size': 3}
-        vkwargs = {'length': 10}
-
-        pkwargs.update(point_kwargs)
-        vkwargs.update(vector_kwargs)
-
-        layer = self.make_points_layer(self.datablock.as_zyx(),
-                                       name=f'{self.name}',
-                                       **pkwargs)
-        self.layers.append(layer)
-
-    def push_changes(self):
-        self.datablock.data = self.layers[0].data
diff --git a/peepingtom/peeper/gui/__init__.py b/peepingtom/peeper/gui/__init__.py
deleted file mode 100644
index 18787c5b..00000000
--- a/peepingtom/peeper/gui/__init__.py
+++ /dev/null
@@ -1,1 +0,0 @@
-from .gui import make_property_widgets
diff --git a/peepingtom/peeper/gui/gui.py b/peepingtom/peeper/gui/gui.py
deleted file mode 100644
index 74eedcb2..00000000
--- a/peepingtom/peeper/gui/gui.py
+++ /dev/null
@@ -1,114 +0,0 @@
-"""
-Gui elements for interfacing peeper and napari
-"""
-
-from enum import Enum
-from math import floor, ceil
-from inspect import signature
-
-import numpy as np
-import napari
-from magicgui import magicgui
-from magicgui._qt.widgets import QDoubleSlider, QDataComboBox
-from napari.layers import Layer, Image, Points
-
-
-class MySlider(QDoubleSlider):
-    def setMinimum(self, value: float):
-        """Set minimum position of slider in float units."""
-        super().setMinimum(int(value * self.PRECISION))
-
-
-def make_property_widgets(points_layer, property_conditions: dict, output_layer='result'):
-    """
-    conditions can be strings such as:
-        - >, <, >=, <=: continuous properties, make sliders to set the cutoff
-        - 'check': property is categorical, make a checkbox for each unique value
-    """
-    available_conds = ('>', '<', '==', '<=', '>=')
-    #sanity check:
-    categorical_props = {}
-    continuous_props = {}
-    for p, c in property_conditions.items():
-        if p not in points_layer.properties:
-            raise ValueError(f'{p} is not a property of {points_layer}')
-        if c == 'check':
-            categorical_props[p] = {}
-            categorical_props[p]['values'] = np.unique(points_layer.properties[p])
-        elif c in available_conds:
-            continuous_props[p] = {}
-            continuous_props[p]['cond'] = c
-            continuous_props[p]['min'] = points_layer.properties[p].min()
-            continuous_props[p]['max'] = points_layer.properties[p].max()
-        else:
-            raise ValueError("conditions can only be 'check' or one of {available_conds}")
-
-    magic_kwargs = {}
-    for prop, params in continuous_props.items():
-        magic_kwargs[prop] = {'widget_type': MySlider, 'minimum': params['min'], 'maximum': params['max']}
-    # programmatically generate function call
-    func_lines = []
-    func_lines.append(f'def magic_widget(layer: Points, {": float, ".join(continuous_props)}: float) -> Layer:')
-    func_lines.append(f'sele = np.ones(len(layer.data))')
-    for prop, params in continuous_props.items():
-        func_lines.append(f'sele_{prop} = layer.properties["{prop}"] {params["cond"]} {prop}')
-        func_lines.append(f'sele = np.logical_and(sele, sele_{prop})')
-    func_lines.append(f'return [(layer.data[sele], {{"name": "{output_layer}", "size": 3 }}, "points")]')
-    func = '\n    '.join(func_lines)
-    # make the widget
-    exec(func, globals())
-    widget_gui = magicgui(magic_widget, auto_call=True, **magic_kwargs)
-
-    return widget_gui.Gui()
-
-
-@magicgui(auto_call=True)
-def checkboxes(class_0: True, class_1: True, class_2: True):
-    pass
-
-
-class Axis(Enum):
-    z = 0
-    y = 1
-    x = 2
-
-zeros = []
-
-@magicgui(auto_call=True,
-          slice_coord={'widget_type': QDoubleSlider, 'fixedWidth': 400, 'maximum': 1},
-          mode={'choices': ['average', 'chunk']})
-def image_slicer(image: Image, slice_coord: float, slice_size: int, axis: Axis, mode = 'chunk') -> Image:
-    im_shape = image.data.shape
-
-    ax_range = im_shape[axis.value] - 1
-    ax_range_padded = ax_range - slice_size
-    slice_coord_real = int(floor(slice_coord * ax_range_padded + slice_size))
-    slice_from = slice_coord_real - floor(slice_size/2)
-    slice_to = slice_coord_real + ceil(slice_size/2)
-
-    global zeros
-    zeros[:] = 0
-    if mode == 'chunk':
-        if axis.value == 0:
-            zeros[slice_from:slice_to,:,:] = image.data[slice_from:slice_to,:,:]
-        elif axis.value == 1:
-            zeros[:,slice_from:slice_to,:] = image.data[:,slice_from:slice_to,:]
-        elif axis.value == 2:
-            zeros[:,:,slice_from:slice_to] = image.data[:,:,slice_from:slice_to]
-    elif mode == 'average':
-        if axis.value == 0:
-            zeros[slice_coord_real,:,:] = image.data[slice_from:slice_to,:,:].mean(axis=0)
-        elif axis.value == 1:
-            zeros[:,slice_coord_real,:] = image.data[:,slice_from:slice_to,:].mean(axis=1)
-        elif axis.value == 2:
-            zeros[:,:,slice_coord_real] = image.data[:,:,slice_from:slice_to].mean(axis=2)
-
-    return zeros
-
-
-def add_widgets(viewer):
-    widget = image_slicer.Gui()
-    viewer.window.add_dock_widget(widget)
-    viewer.layers.events.changed.connect(lambda x: widget.refresh_choices('image'))
-    layer_selection_widget = widget.findChild(QDataComboBox, 'image')
-    layer_selection_widget.currentTextChanged.connect()
diff --git a/peepingtom/peeper/peeper/__init__.py b/peepingtom/peeper/peeper/__init__.py
deleted file mode 100644
index 5a5e3114..00000000
--- a/peepingtom/peeper/peeper/__init__.py
+++ /dev/null
@@ -1,1 +0,0 @@
-from .base import Peeper, peep
diff --git a/peepingtom/peeper/peeper/base.py b/peepingtom/peeper/peeper/base.py
deleted file mode 100644
index 2e37f6a7..00000000
--- a/peepingtom/peeper/peeper/base.py
+++ /dev/null
@@ -1,96 +0,0 @@
-"""
-main class that interfaces visualization, analysis and data manipulation
-"""
-
-import napari
-
-from ...core import ImageBlock, ParticleBlock, PointBlock, LineBlock
-from ..depictors import ImageDepictor, ParticleDepictor, PointDepictor, LineDepictor
-from ...utils import AttributedList
-from ...io_ import read, write
-
-
-class Peeper:
-    """
-    collect and display an arbitrary set of images and/or datasets
-    expose the datasets to visualization and analysis tools
-    """
-    def __init__(self, crates, viewer=None):
-        self.crates = AttributedList(crates)
-        self.viewer = viewer
-        # initialise depictors
-        for crate in crates:
-            for datablock in crate:
-                self._init_depictor(datablock)
-
-    def _init_depictor(self, datablock):
-        depictor_type = {
-            ParticleBlock: ParticleDepictor,
-            ImageBlock: ImageDepictor,
-            PointBlock: PointDepictor,
-            LineBlock: LineDepictor,
-        }
-        try:
-            # don't store a reference to it, cause it hooks itself on the datablock
-            depictor_type[type(datablock)](datablock, peeper=self)
-        except KeyError:
-            raise TypeError(f'cannot find a Depictor for datablock of type {type(datablock)}')
-
-    @property
-    def datablocks(self):
-        return AttributedList(datablock for crate in self.crates for datablock in crate)
-
-    @property
-    def depictors(self):
-        return self.datablocks.depictor
-
-    @property
-    def depictor_layers(self):
-        return self.depictors.layers
-
-    def _get_datablocks(self, block_type):
-        return AttributedList(datablock for datablock in self.datablocks if isinstance(datablock, block_type))
-
-    def _init_viewer(self, viewer=None):
-        # create a new viewer if necessary
-        if viewer is not None:
-            self.viewer = viewer
-        elif self.viewer is None:
-            self.viewer = napari.Viewer(ndisplay=3)
-
-        # random check to make sure viewer was not closed
-        try:
-            self.viewer.window.qt_viewer.actions()
-        except RuntimeError:
-            self.viewer = napari.Viewer(ndisplay=3)
-
-    def peep(self, viewer=None):
-        self._init_viewer(viewer)
-        self.depictors.draw()
-
-    def hide(self):
-        self.depictors.hide()
-
-    def update(self):
-        self.depictors.update()
-
-    def read(self, paths, **kwargs):
-        """
-        read paths into datablocks and append them to the datacrates
-        """
-        self.crates.append(read(paths, **kwargs))
-
-    def write(self, paths, **kwargs):
-        """
-        write datablock contents to disk
-        """
-        write(self.datablocks, paths, **kwargs)
-
-
-def peep(paths, force_mode=None, **kwargs):
-    """
-    load path(s) as DataCrates into a Peeper object and display them in napari
-    """
-    peeper = Peeper(read(paths, mode=force_mode, **kwargs))
-    peeper.peep()
-    return peeper
diff --git a/peepingtom/peeper/peeper/tools/particles.py b/peepingtom/peeper/peeper/tools/particles.py
deleted file mode 100644
index 82620ec9..00000000
--- a/peepingtom/peeper/peeper/tools/particles.py
+++ /dev/null
@@ -1,39 +0,0 @@
-from seaborn import color_palette
-
-from ...io_ import star_to_crates
-from ...analysis.particles import classify_radial_distance
-from .base import Peeper
-from ...core import ParticleBlock, stack
-from ..depictors import ParticleDepictor
-
-
-class ParticlePeeper(Peeper):
-    """
-    Peeper class for Particles objects
-    """
-    def __init__(self, star_paths, **kwargs):
-        crates = star_to_crates(star_paths)
-        super().__init__(crates, **kwargs)
-        self.stack = None
-
-    @property
-    def particles(self):
-        return self._get_datablocks(ParticleBlock)
-
-    def peep(self, viewer=None, as_stack=True, **kwargs):
-        self._init_viewer(viewer)
-        if as_stack:
-            self.stack = stack(self.particles)
-            ParticleDepictor(self.stack, peeper=self)
-            self.stack.depictor.draw()
-        else:
-            self.depictors.draw()
-
-    def classify_radial_distance(self, **kwargs):
-        n_classes = kwargs.pop('n_classes', 5)
-        class_tag = kwargs.pop('class_tag', 'class_radial')
-        colors = [list(x) for x in color_palette('colorblind', n_colors=n_classes)]
-        classify_radial_distance(self.particles, n_classes=n_classes, class_tag=class_tag, **kwargs)
-        for d in self.depictors:
-            d.point_layer.face_color = class_tag
-            d.point_layer.face_color_cycle = colors
diff --git a/peepingtom/utils/__init__.py b/peepingtom/utils/__init__.py
index 6ed5538a..d1bc5a8d 100644
--- a/peepingtom/utils/__init__.py
+++ b/peepingtom/utils/__init__.py
@@ -1,2 +1,4 @@
-from .linalg_helper import align_vectors
-from .containers import AttributedList
+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/peepingtom/utils/colors.py b/peepingtom/utils/colors.py
new file mode 100644
index 00000000..e34e16d1
--- /dev/null
+++ b/peepingtom/utils/colors.py
@@ -0,0 +1,28 @@
+# 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/peepingtom/utils/containers.py b/peepingtom/utils/containers.py
deleted file mode 100644
index 0b3d72e8..00000000
--- a/peepingtom/utils/containers.py
+++ /dev/null
@@ -1,65 +0,0 @@
-from collections.abc import Iterable
-
-
-class AttributedList(list):
-    """
-    a list that accepts dot notation to return attributes of its elements, if all
-    contained elements have that attribute and itself does not
-
-    You can also set attributes and call methods of the nested elements.
-    If setattr is called with a value of type AttributedList, the list will attempt to set the attributes
-    element by element
-    """
-    def __getattribute__(self, name):
-        try:
-            return super().__getattribute__(name)
-        except AttributeError as e:
-            try:
-                attrs = AttributedList()
-                for item in self:
-                    # this lets us go as deep as needed, if working with nested lists
-                    if isinstance(item, (list, AttributedList)):
-                        for sub_item in AttributedList(item):
-                            try:
-                                attrs.extend(sub_item.__getattribute__(name))
-                            # when finding methods, this is raised
-                            # TODO: why?
-                            except TypeError:
-                                attrs.append(sub_item.__getattribute__(name))
-                    else:
-                        attrs.append(item.__getattribute__(name))
-                return attrs
-            except AttributeError:
-                raise e
-
-    def __setattr__(self, name, value):
-        # check first in the normal way
-        if hasattr(list(self), name):
-            list.__setattr__(self, name, value)
-
-        # can only safely do this recursively if we know where to find the atttributes lower down
-        elif hasattr(self, name):
-            zip_values = None
-
-            # if another Attributed list is fed, do it value by value if possible
-            if isinstance(value, AttributedList):
-                # len(self) is not enough because it might be nested
-                if len(self.__getattribute__(name)) == len(value):
-                    zip_values = list(reversed(value))  # reversed for popping
-
-            for item in self:
-                if zip_values is not None:
-                    # if an attributed list was given, use it value by value
-                    value = zip_values.pop()
-                # this lets us go as deep as needed, if working with nested lists
-                if isinstance(item, Iterable):
-                    for sub_item in AttributedList(item):
-                        sub_item.__setattr__(name, value)
-                else:
-                    item.__setattr__(name, value)
-        else:
-            raise AttributeError
-
-
-    def __call__(self, *args, **kwargs):
-        return AttributedList(item.__call__(*args, **kwargs) for item in self)
diff --git a/peepingtom/utils/dispatchlist.py b/peepingtom/utils/dispatchlist.py
new file mode 100644
index 00000000..f9d40b85
--- /dev/null
+++ b/peepingtom/utils/dispatchlist.py
@@ -0,0 +1,189 @@
+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/peepingtom/utils/generic.py b/peepingtom/utils/generic.py
new file mode 100644
index 00000000..a1f388f3
--- /dev/null
+++ b/peepingtom/utils/generic.py
@@ -0,0 +1,63 @@
+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/peepingtom/utils/linalg_helper.py b/peepingtom/utils/linalg_helper.py
index f471cc8b..47b7fbf0 100644
--- a/peepingtom/utils/linalg_helper.py
+++ b/peepingtom/utils/linalg_helper.py
@@ -35,3 +35,15 @@ def align_vectors(a: np.ndarray, b: np.ndarray):
     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)
+        indexes.append(idx)
+    return indexes
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 00000000..c558a5f3
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,10 @@
+[build-system]
+requires = [
+  "setuptools >= 42",
+  "wheel",
+  "setuptools_scm[toml]>=3.4"
+]
+build-backend = "setuptools.build_meta"
+
+[tool.setuptools_scm]
+write_to = "peepingtom/_version.py"
diff --git a/requirements.txt b/requirements.txt
index fc264b60..525e3e70 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,10 +1,1 @@
-napari[all]==0.4.2
-numpy~=1.19
-pandas~=1.1
-scipy~=1.5
-seaborn~=0.11
-dynamotable~=0.2.3
-eulerangles~=0.1.1
-mrcfile~=1.1.2
-starfile~=0.4.4
-emfile~=0.2
+-e ".[dev]"
diff --git a/setup.cfg b/setup.cfg
index 72e27393..8f8f94f5 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,10 +1,9 @@
 [metadata]
 name = peepingtom
-version = attr: peepingtom._version.__version__
 url = https://github.com/gutsche-lab/peepingtom/
 license = BSD 3-Clause
 license_file = LICENSE
-description = Python tool for interactive visualisation and analysis of tomography data.
+description = Python tool for visualising and interacting with cryo-ET and subtomogram averaging data.
 long_description = file: README.md
 long_description_content_type = text/markdown
 author = PeepingTom Team
@@ -36,26 +35,36 @@ packages = find:
 python_requires = >= 3.8
 include_package_data = True
 install_requires =
-    napari[all]==0.4.2
-    numpy~=1.19
-    pandas~=1.1
-    scipy~=1.5
-    seaborn~=0.11
-    dynamotable~=0.2.3
-    eulerangles~=0.1.1
-    mrcfile~=1.1.2
-    starfile~=0.4.1
+    napari[all]>=0.4.8
+    numpy
+    pandas
+    scipy
+    pyqtgraph
+    click
+    dynamotable~=0.2.4
+    eulerangles~=1.0.1
+    mrcfile~=1.3.0
+    starfile~=0.4.4
     emfile~=0.2
 
 
+[options.extras_require]
+dev =
+    pytest
+    pytest-qt
+
+
+[options.entry_points]
+console_scripts =
+    peep = peepingtom.__main__:cli
+
 
 [options.package_data]
 * = *.txt, *.md
 
 
 [flake8]
-# W605: invalid escape sequence (regex does not play well with this)
-ignore = W605
-# gui.py is excluded because it has a lot of weird metaprogramming stuff. TODO: remove when old!
-exclude = __init__.py,docs,examples,peepingtom/visualisation/gui/gui.py
-max-line-length = 110
+# E116, E117: unexpected indentations in comment (happens when commenting out code)
+ignore = E116,E117
+exclude = __init__.py,docs,examples
+max-line-length = 120
diff --git a/setup.py b/setup.py
index 60684932..2ae16a12 100644
--- a/setup.py
+++ b/setup.py
@@ -1,3 +1,10 @@
+"""
+PEP 517 doesn’t support editable installs
+so this file is currently here to support "pip install -e ."
+"""
 from setuptools import setup
 
-setup()
+setup(
+    use_scm_version={"write_to": "peepingtom/_version.py"},
+    setup_requires=["setuptools_scm"],
+)


Test patch:
diff --git a/.github/workflows/test_pr.yml b/.github/workflows/test_pr.yml
index 2680b2ea..0ae47e20 100644
--- a/.github/workflows/test_pr.yml
+++ b/.github/workflows/test_pr.yml
@@ -6,22 +6,32 @@ on:
 
 jobs:
   pytest:
+    name: Test Pull Request
     runs-on: ubuntu-latest
+    if: github.repository == 'gutsche-lab/peepingtom'
     steps:
-      - uses: actions/checkout@v2
-      - name: Install Python 3
-        uses: actions/setup-python@v1
-        with:
-          python-version: 3.8
-      - name: Install requirements
-        run: |
-          python -m pip install --upgrade pip
-          pip install pytest-cov codecov
-          pip install -r requirements.txt
-      - name: Run tests
-        run: |
-          pytest --cov=./peepingtom
-          codecov
-      - name: Upload report to codecov
-        run: |
-          bash <(curl -s https://codecov.io/bash)
+    - name: Checkout code
+      uses: actions/checkout@v2
+      with:
+        fetch-depth: 2
+    - name: Install Python
+      uses: actions/setup-python@v2
+      with:
+        python-version: 3.8
+    - name: Install qt libraries
+      run: |
+        sudo apt-get install -y libdbus-1-3 libxkbcommon-x11-0 libxcb-icccm4 \
+          libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 \
+          libxcb-xinerama0 libxcb-xinput0 libxcb-xfixes0 xvfb
+    - name: Install Dependencies
+      run: |
+        python -m pip install --upgrade pip
+        python -m pip install pytest-cov pytest-qt codecov
+        python -m pip install -r requirements.txt
+    - name: Run Tests
+      run: |
+        xvfb-run pytest --cov=./peepingtom
+        codecov
+    - name: Upload report to codecov
+      run: |
+        bash <(curl -s https://codecov.io/bash)
diff --git a/tests/__init__.py b/peepingtom/_tests/__init__.py
similarity index 100%
rename from tests/__init__.py
rename to peepingtom/_tests/__init__.py
diff --git a/peepingtom/_tests/test_cli.py b/peepingtom/_tests/test_cli.py
new file mode 100644
index 00000000..c6f7e725
--- /dev/null
+++ b/peepingtom/_tests/test_cli.py
@@ -0,0 +1,9 @@
+from click.testing import CliRunner
+
+from peepingtom.__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/peepingtom/_tests/test_peep.py b/peepingtom/_tests/test_peep.py
new file mode 100644
index 00000000..9013567f
--- /dev/null
+++ b/peepingtom/_tests/test_peep.py
@@ -0,0 +1,28 @@
+import numpy as np
+import mrcfile
+
+from peepingtom.functions import peep
+from peepingtom.datablocks import PointBlock
+from peepingtom.peeper import Peeper
+
+
+def test_peep_peeper():
+    peeper = peep(Peeper())
+    assert isinstance(peeper, Peeper)
+    assert len(peeper) == 0
+
+
+def test_peep_datablock():
+    peeper = peep(PointBlock(data=()))
+    assert isinstance(peeper, Peeper)
+    assert len(peeper) == 1
+    peeper = peep([PointBlock(data=()), PointBlock(data=())])
+    assert isinstance(peeper, Peeper)
+    assert len(peeper) == 2
+
+
+def test_peep_path(tmp_path):
+    mrc_path = str(tmp_path / 'test.mrc')
+    mrcfile.new(mrc_path, np.random.rand(10, 10, 10).astype(np.float32))
+    peeper = peep(mrc_path)
+    assert isinstance(peeper, Peeper)
diff --git a/peepingtom/_tests/test_peeper.py b/peepingtom/_tests/test_peeper.py
new file mode 100644
index 00000000..d51b5526
--- /dev/null
+++ b/peepingtom/_tests/test_peeper.py
@@ -0,0 +1,46 @@
+import pytest
+import numpy as np
+
+from peepingtom.peeper import Peeper
+from peepingtom.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='PT_OMNI')
+peeper = Peeper([pb, ib, pb_novolume, pb_omni])
+
+
+def test_peeper():
+    assert len(peeper) == 4
+    assert len(peeper.datablocks) == 4
+    assert len(peeper.volumes) == 2
+    assert len(peeper.omni) == 1
+    assert len(peeper.images) == 1
+
+
+def test_sanitize_peeper():
+    with pytest.raises(TypeError):
+        peeper.append('wrong')
+
+
+def test_view():
+    view = peeper[:1]
+    assert len(view) == 1
+    assert view._view_of is peeper
+    with pytest.raises(TypeError):
+        view.append(pb)
+
+
+def test_pprint():
+    brep = peeper.__pretty_repr__('base')
+    frep = peeper.__pretty_repr__('full')
+    assert len(brep.split('\n')) == 1
+    assert len(frep.split('\n')) == 8
+
+
+def test_depict():
+    peeper.datablocks.init_depictor()
+    peeper.depictors.depict()
+    assert len(peeper.napari_layers) == 4
diff --git a/peepingtom/datablocks/abstractblocks/_tests/test_datablock.py b/peepingtom/datablocks/abstractblocks/_tests/test_datablock.py
new file mode 100644
index 00000000..77729449
--- /dev/null
+++ b/peepingtom/datablocks/abstractblocks/_tests/test_datablock.py
@@ -0,0 +1,10 @@
+from pytest import raises
+
+from peepingtom.datablocks.abstractblocks.datablock import DataBlock
+
+
+def test_datablock():
+    db = DataBlock()
+
+    with raises(ValueError):
+        db.init_depictor()
diff --git a/peepingtom/datablocks/abstractblocks/_tests/test_multiblock.py b/peepingtom/datablocks/abstractblocks/_tests/test_multiblock.py
new file mode 100644
index 00000000..50a2c243
--- /dev/null
+++ b/peepingtom/datablocks/abstractblocks/_tests/test_multiblock.py
@@ -0,0 +1,6 @@
+from peepingtom.datablocks.abstractblocks.multiblock import MultiBlock
+
+
+def test_multiblock():
+    block = MultiBlock()
+    assert isinstance(block, MultiBlock)
diff --git a/peepingtom/datablocks/abstractblocks/_tests/test_simpleblock.py b/peepingtom/datablocks/abstractblocks/_tests/test_simpleblock.py
new file mode 100644
index 00000000..52a4b7ad
--- /dev/null
+++ b/peepingtom/datablocks/abstractblocks/_tests/test_simpleblock.py
@@ -0,0 +1,17 @@
+import pytest
+
+from peepingtom.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/tests/analysis/__init__.py b/peepingtom/datablocks/multiblocks/_tests/__init__.py
similarity index 100%
rename from tests/analysis/__init__.py
rename to peepingtom/datablocks/multiblocks/_tests/__init__.py
diff --git a/tests/core/datablocks/test_dipoleblock.py b/peepingtom/datablocks/multiblocks/_tests/test_dipoleblock.py
similarity index 80%
rename from tests/core/datablocks/test_dipoleblock.py
rename to peepingtom/datablocks/multiblocks/_tests/test_dipoleblock.py
index f45227a6..876869be 100644
--- a/tests/core/datablocks/test_dipoleblock.py
+++ b/peepingtom/datablocks/multiblocks/_tests/test_dipoleblock.py
@@ -1,7 +1,7 @@
 import numpy as np
 from numpy.testing import assert_array_equal, assert_array_almost_equal
 
-from peepingtom.core import DipoleBlock
+from peepingtom.datablocks.multiblocks.dipoleblock import DipoleBlock
 
 test_centers = np.asarray([[0, 0, 0],
                            [1, 1, 1],
@@ -16,22 +16,22 @@
 
 
 def test_dipoleblock_instantiation():
-    block = DipoleBlock(test_centers, test_endpoints)
+    block = DipoleBlock(startpoints_data=test_centers, endpoints_data=test_endpoints)
     assert isinstance(block, DipoleBlock)
 
 
 def test_dipoleblock_orientation_vectors():
-    block = DipoleBlock(test_centers, test_endpoints)
+    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(test_centers, test_endpoints)
+    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(test_centers, test_endpoints)
+    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)
diff --git a/tests/core/datablocks/test_meshblock.py b/peepingtom/datablocks/multiblocks/_tests/test_meshblock.py
similarity index 70%
rename from tests/core/datablocks/test_meshblock.py
rename to peepingtom/datablocks/multiblocks/_tests/test_meshblock.py
index 184b9027..9a5cb2d9 100644
--- a/tests/core/datablocks/test_meshblock.py
+++ b/peepingtom/datablocks/multiblocks/_tests/test_meshblock.py
@@ -1,6 +1,6 @@
 import numpy as np
 
-from peepingtom.core import MeshBlock
+from peepingtom.datablocks.multiblocks.meshblock import MeshBlock
 
 vertices = np.array([[0, 0, 0],
                      [0, 0, 1],
@@ -21,20 +21,20 @@
 
 def test_meshblock_instantiation():
     # check that meshblock can be instantiated properly
-    block = MeshBlock(vertices, faces)
+    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, faces)
+    block = MeshBlock(vertices_data=vertices, faces_data=faces)
     triangles = block.triangles
-    assert triangles.shape[0] == faces.shape[0]
-    assert triangles.shape[2] == 3
+    assert len(triangles) == len(faces)
+    assert triangles.shape[-1] == 3
 
 
 def test_midpoints():
-    block = MeshBlock(vertices, faces)
+    block = MeshBlock(vertices_data=vertices, faces_data=faces)
     midpoints = block.midpoints
     assert midpoints.shape == (faces.shape[0], 3)
diff --git a/peepingtom/datablocks/multiblocks/_tests/test_particleblock.py b/peepingtom/datablocks/multiblocks/_tests/test_particleblock.py
new file mode 100644
index 00000000..50f327d4
--- /dev/null
+++ b/peepingtom/datablocks/multiblocks/_tests/test_particleblock.py
@@ -0,0 +1,8 @@
+import numpy as np
+
+from peepingtom.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/peepingtom/datablocks/multiblocks/_tests/test_sphereblock.py b/peepingtom/datablocks/multiblocks/_tests/test_sphereblock.py
new file mode 100644
index 00000000..ddec90a3
--- /dev/null
+++ b/peepingtom/datablocks/multiblocks/_tests/test_sphereblock.py
@@ -0,0 +1,10 @@
+import numpy as np
+
+from peepingtom.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/tests/core/datablocks/test_transformblock.py b/peepingtom/datablocks/multiblocks/_tests/test_transformblock.py.old
similarity index 84%
rename from tests/core/datablocks/test_transformblock.py
rename to peepingtom/datablocks/multiblocks/_tests/test_transformblock.py.old
index 1a7f4454..e3234393 100644
--- a/tests/core/datablocks/test_transformblock.py
+++ b/peepingtom/datablocks/multiblocks/_tests/test_transformblock.py.old
@@ -1,7 +1,7 @@
 import numpy as np
 
-from peepingtom.core import TransformBlock, DipoleBlock, PointBlock, ParticleBlock
-from tests.test_data.blocks import particleblock
+from peepingtom.datablocks.multiblocks.transformblock import TransformBlock
+from peepingtom.datablocks import DipoleBlock, PointBlock, ParticleBlock
 
 # test transform
 shifts = [
@@ -28,6 +28,7 @@ def test_transformblock_instantiation():
 def test_transformblock_application():
     block = TransformBlock(test_shifts, test_orientations, {})
 
+    particleblock = ParticleBlock(np.ones((2, 3)), np.ones((2, 3, 3)), {})
     # apply transformations in block on particles
     new_block = block.apply_on(particleblock)
 
diff --git a/tests/core/__init__.py b/peepingtom/datablocks/simpleblocks/_tests/__init__.py
similarity index 100%
rename from tests/core/__init__.py
rename to peepingtom/datablocks/simpleblocks/_tests/__init__.py
diff --git a/peepingtom/datablocks/simpleblocks/_tests/test_imageblock.py b/peepingtom/datablocks/simpleblocks/_tests/test_imageblock.py
new file mode 100644
index 00000000..548703b2
--- /dev/null
+++ b/peepingtom/datablocks/simpleblocks/_tests/test_imageblock.py
@@ -0,0 +1,16 @@
+import numpy as np
+from peepingtom.datablocks.simpleblocks.imageblock import ImageBlock
+from peepingtom.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/tests/core/datablocks/test_lineblock.py b/peepingtom/datablocks/simpleblocks/_tests/test_lineblock.py
similarity index 78%
rename from tests/core/datablocks/test_lineblock.py
rename to peepingtom/datablocks/simpleblocks/_tests/test_lineblock.py
index 90491e3f..999cfbd7 100644
--- a/tests/core/datablocks/test_lineblock.py
+++ b/peepingtom/datablocks/simpleblocks/_tests/test_lineblock.py
@@ -1,6 +1,6 @@
 import numpy as np
 
-from peepingtom.core.datablocks import LineBlock
+from peepingtom.datablocks.simpleblocks.lineblock import LineBlock
 
 # test data for lineblock
 v = np.linspace(0, 12)
@@ -10,13 +10,13 @@
 
 def test_lineblock_instantiation():
     # test LineBlock instantiation
-    LineBlock(line_2d)
-    LineBlock(line_3d)
+    LineBlock(data=line_2d)
+    LineBlock(data=line_3d)
 
 
 def test_lineblock_fit_spline():
     # test LineBlock.fit_spline
-    block = LineBlock(line_3d)
+    block = LineBlock(data=line_3d)
     tck = block.fit_spline('xyz')
 
     assert block._tck is not None
@@ -25,7 +25,7 @@ def test_lineblock_fit_spline():
 
 def test_lineblock_evaluate_spline():
     # test LineBlock.evaluate_spline
-    block = LineBlock(line_3d)
+    block = LineBlock(data=line_3d)
     block.fit_spline('xyz')
     for n in [10, 100, 1000]:
         spline = block.evaluate_spline(n)
diff --git a/peepingtom/datablocks/simpleblocks/_tests/test_orientationblock.py b/peepingtom/datablocks/simpleblocks/_tests/test_orientationblock.py
new file mode 100644
index 00000000..eb200588
--- /dev/null
+++ b/peepingtom/datablocks/simpleblocks/_tests/test_orientationblock.py
@@ -0,0 +1,18 @@
+import numpy as np
+from numpy.testing import assert_array_equal
+from eulerangles import euler2matrix
+
+from peepingtom.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/tests/core/datablocks/test_pointblock.py b/peepingtom/datablocks/simpleblocks/_tests/test_pointblock.py
similarity index 54%
rename from tests/core/datablocks/test_pointblock.py
rename to peepingtom/datablocks/simpleblocks/_tests/test_pointblock.py
index c68b311a..9bcda53c 100644
--- a/tests/core/datablocks/test_pointblock.py
+++ b/peepingtom/datablocks/simpleblocks/_tests/test_pointblock.py
@@ -2,7 +2,7 @@
 import numpy as np
 from numpy.testing import assert_array_equal
 
-from peepingtom.core.datablocks import PointBlock
+from peepingtom.datablocks.simpleblocks.pointblock import PointBlock
 
 # test data pointblock
 single_point_2d = [1, 2]
@@ -16,57 +16,51 @@
 
 def test_pointblock_instantiation():
     # test instantiation for 2d, 3d and nd points
-    PointBlock(single_point_2d)
-    PointBlock(points_2d)
-    PointBlock(single_point_3d)
-    PointBlock(points_3d)
-    PointBlock(point_nd)
+    PointBlock(data=single_point_2d)
+    PointBlock(data=points_2d)
+    PointBlock(data=single_point_3d)
+    PointBlock(data=points_3d)
 
 
 def test_pointblock_xyz():
     # test 'x', 'y' and 'z' properties
-    block = PointBlock(single_point_2d)
-    assert_array_equal(block.x, [1])
-    assert_array_equal(block.y, [2])
+    block = PointBlock(data=single_point_2d)
+    assert_array_equal(block.x, [[1]])
+    assert_array_equal(block.y, [[2]])
 
-    block = PointBlock(single_point_3d)
-    assert_array_equal(block.x, [1])
-    assert_array_equal(block.y, [2])
-    assert_array_equal(block.z, [3])
+    block = PointBlock(data=single_point_3d)
+    assert_array_equal(block.x, [[1]])
+    assert_array_equal(block.y, [[2]])
+    assert_array_equal(block.z, [[3]])
 
 
-def test_pointblock_get_named_dimension():
-    # test _get_named_dimension method
-    block = PointBlock(single_point_3d)
+def test_pointblock_get_named_dimensions():
+    # test _get_named_dimensions method
+    block = PointBlock(data=single_point_3d)
 
-    # check output is array
-    x = block._get_named_dimension('x')
+    x = block._get_named_dimensions('x')
     assert isinstance(x, np.ndarray)
 
     # try with multiple named dims
-    xyz = block._get_named_dimension('xyz')
+    xyz = block._get_named_dimensions('xyz')
     assert isinstance(xyz, np.ndarray)
     assert xyz.shape == (1, 3)
 
-    xyz = block._get_named_dimension('xyz', as_type='tuple')
-    assert isinstance(xyz, tuple)
-    assert len(xyz) == 3
-
 
 def test_pointblock_center_of_mass():
     # test center_of_mass property
     # single point case
-    block = PointBlock(single_point_3d)
+    block = PointBlock(data=single_point_3d)
     assert_array_equal(block.center_of_mass, single_point_3d)
 
     # multi point case
-    block = PointBlock(points_3d)
+    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(single_point_3d)
+    block = PointBlock(data=single_point_3d)
 
     assert block.distance_to([1, 2, 3]) == 0
     assert block.distance_to([2, 3, 4]) == np.sqrt(3)
diff --git a/peepingtom/datablocks/simpleblocks/_tests/test_propertyblock.py b/peepingtom/datablocks/simpleblocks/_tests/test_propertyblock.py
new file mode 100644
index 00000000..03bb082b
--- /dev/null
+++ b/peepingtom/datablocks/simpleblocks/_tests/test_propertyblock.py
@@ -0,0 +1,6 @@
+from peepingtom.datablocks.simpleblocks.propertyblock import PropertyBlock
+
+
+def test_propertyblock_instantiation():
+    block = PropertyBlock(data={})
+    assert isinstance(block, PropertyBlock)
diff --git a/tests/core/datablocks/test_sphereblock.py b/peepingtom/datablocks/simpleblocks/_tests/test_sphereblock.py
similarity index 100%
rename from tests/core/datablocks/test_sphereblock.py
rename to peepingtom/datablocks/simpleblocks/_tests/test_sphereblock.py
diff --git a/tests/core/datablocks/__init__.py b/peepingtom/depictors/napari/_tests/__init__.py
similarity index 100%
rename from tests/core/datablocks/__init__.py
rename to peepingtom/depictors/napari/_tests/__init__.py
diff --git a/peepingtom/depictors/napari/_tests/test_images.py b/peepingtom/depictors/napari/_tests/test_images.py
new file mode 100644
index 00000000..75fdc509
--- /dev/null
+++ b/peepingtom/depictors/napari/_tests/test_images.py
@@ -0,0 +1,15 @@
+import numpy as np
+from napari.layers import Image
+
+from peepingtom.datablocks import ImageBlock
+from peepingtom.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/peepingtom/depictors/napari/_tests/test_lines.py b/peepingtom/depictors/napari/_tests/test_lines.py
new file mode 100644
index 00000000..49e3fc71
--- /dev/null
+++ b/peepingtom/depictors/napari/_tests/test_lines.py
@@ -0,0 +1,17 @@
+import numpy as np
+from napari.layers import Shapes, Points
+
+from peepingtom.datablocks import LineBlock
+from peepingtom.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/peepingtom/depictors/napari/_tests/test_particles.py b/peepingtom/depictors/napari/_tests/test_particles.py
new file mode 100644
index 00000000..a76afe17
--- /dev/null
+++ b/peepingtom/depictors/napari/_tests/test_particles.py
@@ -0,0 +1,20 @@
+import numpy as np
+from napari.layers import Vectors, Points
+
+from peepingtom.datablocks import ParticleBlock
+from peepingtom.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/peepingtom/depictors/napari/_tests/test_points.py b/peepingtom/depictors/napari/_tests/test_points.py
new file mode 100644
index 00000000..1ce68ecb
--- /dev/null
+++ b/peepingtom/depictors/napari/_tests/test_points.py
@@ -0,0 +1,15 @@
+import numpy as np
+from napari.layers import Points
+
+from peepingtom.datablocks import PointBlock
+from peepingtom.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/tests/peeper/__init__.py b/peepingtom/gui/_tests/__init__.py
similarity index 100%
rename from tests/peeper/__init__.py
rename to peepingtom/gui/_tests/__init__.py
diff --git a/peepingtom/gui/_tests/test_viewer.py b/peepingtom/gui/_tests/test_viewer.py
new file mode 100644
index 00000000..ed4a552a
--- /dev/null
+++ b/peepingtom/gui/_tests/test_viewer.py
@@ -0,0 +1,13 @@
+from peepingtom.peeper import Peeper
+from peepingtom.datablocks import PointBlock
+
+
+def test_viewer(make_napari_viewer):
+    p = Peeper(PointBlock(data=[[0, 0, 0]]))
+    viewer = make_napari_viewer(strict_qt=False)
+    p.show(napari_viewer=viewer)
+    p.viewer.toggle_plots()
+    p.viewer.update_pt_widget()
+    p.viewer.previous_volume()
+    p.viewer.next_volume()
+    p.purge_gui()
diff --git a/tests/peeper/test_depictors/__init__.py b/peepingtom/io_/reading/_tests/__init__.py
similarity index 100%
rename from tests/peeper/test_depictors/__init__.py
rename to peepingtom/io_/reading/_tests/__init__.py
diff --git a/peepingtom/io_/reading/_tests/test_read.py b/peepingtom/io_/reading/_tests/test_read.py
new file mode 100644
index 00000000..e20863ef
--- /dev/null
+++ b/peepingtom/io_/reading/_tests/test_read.py
@@ -0,0 +1,35 @@
+import numpy as np
+import pandas as pd
+import mrcfile
+import starfile
+
+from peepingtom.io_.reading.main import read
+
+
+def test_read(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)
+
+    p = read(tmp_path / '*', name_regex=r'test\d')
+
+    assert len(p) == 4
+    assert len(p.volumes) == 2
diff --git a/peepingtom/io_/reading/_tests/test_read_em.py b/peepingtom/io_/reading/_tests/test_read_em.py
new file mode 100644
index 00000000..1389f502
--- /dev/null
+++ b/peepingtom/io_/reading/_tests/test_read_em.py
@@ -0,0 +1,13 @@
+import numpy as np
+import emfile
+
+from peepingtom.io_.reading.em import read_em
+from peepingtom.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/peepingtom/io_/reading/_tests/test_read_mrc.py b/peepingtom/io_/reading/_tests/test_read_mrc.py
new file mode 100644
index 00000000..d85de7eb
--- /dev/null
+++ b/peepingtom/io_/reading/_tests/test_read_mrc.py
@@ -0,0 +1,13 @@
+import numpy as np
+import mrcfile
+
+from peepingtom.io_.reading.mrc import read_mrc
+from peepingtom.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/peepingtom/io_/reading/_tests/test_read_star.py b/peepingtom/io_/reading/_tests/test_read_star.py
new file mode 100644
index 00000000..0f940ec5
--- /dev/null
+++ b/peepingtom/io_/reading/_tests/test_read_star.py
@@ -0,0 +1,52 @@
+import pandas as pd
+import starfile
+
+from peepingtom.io_.reading.star import read_star
+from peepingtom.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/peepingtom/io_/reading/_tests/test_read_tbl.py b/peepingtom/io_/reading/_tests/test_read_tbl.py
new file mode 100644
index 00000000..b837228d
--- /dev/null
+++ b/peepingtom/io_/reading/_tests/test_read_tbl.py
@@ -0,0 +1,25 @@
+import pandas as pd
+import dynamotable
+
+from peepingtom.io_.reading.tbl import read_tbl
+from peepingtom.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/tests/test_data/__init__.py b/peepingtom/io_/writing/_tests/__init__.py
similarity index 100%
rename from tests/test_data/__init__.py
rename to peepingtom/io_/writing/_tests/__init__.py
diff --git a/peepingtom/io_/writing/_tests/test_write_em.py b/peepingtom/io_/writing/_tests/test_write_em.py
new file mode 100644
index 00000000..d2c0a3aa
--- /dev/null
+++ b/peepingtom/io_/writing/_tests/test_write_em.py
@@ -0,0 +1,10 @@
+import numpy as np
+
+from peepingtom.io_.writing.em import write_em
+from peepingtom.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/peepingtom/io_/writing/_tests/test_write_mrc.py b/peepingtom/io_/writing/_tests/test_write_mrc.py
new file mode 100644
index 00000000..b6a21837
--- /dev/null
+++ b/peepingtom/io_/writing/_tests/test_write_mrc.py
@@ -0,0 +1,10 @@
+import numpy as np
+
+from peepingtom.io_.writing.mrc import write_mrc
+from peepingtom.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/peepingtom/io_/writing/_tests/test_write_star.py b/peepingtom/io_/writing/_tests/test_write_star.py
new file mode 100644
index 00000000..72b30fb2
--- /dev/null
+++ b/peepingtom/io_/writing/_tests/test_write_star.py
@@ -0,0 +1,13 @@
+import numpy as np
+
+from peepingtom.io_.writing.star import write_star
+from peepingtom.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/peepingtom/utils/_tests/test_dispatch_list.py b/peepingtom/utils/_tests/test_dispatch_list.py
new file mode 100644
index 00000000..08fffe7b
--- /dev/null
+++ b/peepingtom/utils/_tests/test_dispatch_list.py
@@ -0,0 +1,20 @@
+import pytest
+
+from peepingtom.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']
diff --git a/tests/analysis/test_particles.py b/tests/analysis/test_particles.py
deleted file mode 100644
index 35a16d48..00000000
--- a/tests/analysis/test_particles.py
+++ /dev/null
@@ -1,25 +0,0 @@
-import numpy as np
-
-from peepingtom.core.datablocks import ParticleBlock, PointBlock, OrientationBlock, PropertyBlock
-from peepingtom.analysis.particles import classify_radial_distance
-
-
-pt = PointBlock(np.zeros((10, 3)))
-ori = OrientationBlock(np.zeros((10, 3, 3)))
-prop = PropertyBlock({})
-
-
-def test_classify_radial_distance():
-    p = ParticleBlock(pt, ori, prop)
-    centroids, classes = classify_radial_distance([p], n_classes=1, inplace=False)
-    assert isinstance(centroids, np.ndarray)
-    assert len(centroids) == 1
-    assert isinstance(classes, np.ndarray)
-    assert len(classes) == len(pt)
-
-
-def test_classify_radial_distance_inplace():
-    p = ParticleBlock(pt, ori, prop)
-    classify_radial_distance([p], n_classes=1)
-    added_props = ['class_radial', 'class_radial_params']
-    assert all([x in p.properties for x in added_props])
diff --git a/tests/core/datablocks/test_base.py b/tests/core/datablocks/test_base.py
deleted file mode 100644
index 61c9f431..00000000
--- a/tests/core/datablocks/test_base.py
+++ /dev/null
@@ -1,53 +0,0 @@
-"""
-Tests for DataBlock objects
-"""
-
-import pytest
-
-from peepingtom.core import SimpleBlock, MultiBlock, DataCrate, Model
-from ...test_data.blocks import pointblock, lineblock, orientationblock
-
-
-def test_simpleblock():
-    # assert that SimpleBlock class cannot be instantiated directly
-    with pytest.raises(NotImplementedError):
-        SimpleBlock([])
-
-    # assert that subclassing and implementing _data_setter works
-    class SubBlock(SimpleBlock):
-        def __init__(self, data, **kwargs):
-            super().__init__(data, **kwargs)
-
-        def _data_setter(self, data):
-            return data
-
-    subblock = SubBlock([])
-    assert isinstance(subblock, (SimpleBlock, SubBlock))
-
-    # assert that subclass has parent attribute
-    assert hasattr(subblock, 'parent')
-
-    # assert that subclassing and not implementing _data_setter fails on subclass instantiation
-    class SubBlock(SimpleBlock):
-        def __init__(self, **kwargs):
-            super().__init__(**kwargs)
-
-    with pytest.raises(TypeError):
-        subblock = SubBlock()
-
-
-def test_multiblock():
-    block = MultiBlock()
-    assert isinstance(block, MultiBlock)
-
-
-def test_datacrate():
-    # assert that datacrate instantiates properly
-    crate = DataCrate([pointblock, lineblock, orientationblock])
-    assert isinstance(crate, DataCrate)
-
-
-def test_model():
-    # assert that Model class cannot be instantiated directly
-    with pytest.raises(TypeError):
-        Model()
diff --git a/tests/core/datablocks/test_imageblock.py b/tests/core/datablocks/test_imageblock.py
deleted file mode 100644
index 71ef1d56..00000000
--- a/tests/core/datablocks/test_imageblock.py
+++ /dev/null
@@ -1,8 +0,0 @@
-import numpy as np
-from peepingtom.core.datablocks import ImageBlock
-
-
-def test_imageblock_instantiation():
-    im = np.random.normal(0, 1, (28, 28, 28))
-    block = ImageBlock(im)
-    assert isinstance(block, ImageBlock)
diff --git a/tests/core/datablocks/test_orientationblock.py b/tests/core/datablocks/test_orientationblock.py
deleted file mode 100644
index f3a29240..00000000
--- a/tests/core/datablocks/test_orientationblock.py
+++ /dev/null
@@ -1,33 +0,0 @@
-import numpy as np
-from numpy.testing import assert_array_equal
-from eulerangles import euler2matrix
-
-from peepingtom.core.datablocks 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, positive_ccw=True)
-
-
-def test_orientationblock_instantiation():
-    block = OrientationBlock(rotation_matrices)
-    assert isinstance(block, OrientationBlock)
-
-
-def test_orientationblock_unit_vectors():
-    block = OrientationBlock(rotation_matrices)
-    x = block._unit_vector('x')
-    y = block._unit_vector('y')
-    z = block._unit_vector('z')
-
-    unit_x = np.asarray([1, 0, 0]).reshape(3, 1)
-    unit_y = np.asarray([0, 1, 0]).reshape(3, 1)
-    unit_z = np.asarray([0, 0, 1]).reshape(3, 1)
-
-    assert_array_equal(x, unit_x)
-    assert_array_equal(y, unit_y)
-    assert_array_equal(z, unit_z)
diff --git a/tests/core/datablocks/test_particleblock.py b/tests/core/datablocks/test_particleblock.py
deleted file mode 100644
index 0f5c4ce5..00000000
--- a/tests/core/datablocks/test_particleblock.py
+++ /dev/null
@@ -1,8 +0,0 @@
-import numpy as np
-
-# test data for ParticleBlock
-# positions
-z = np.linspace(0, 6 * np.pi, 50)
-x = 3 * np.sin(z)
-y = 3 * np.cos(z)
-xyz = np.column_stack([x, y, z])
diff --git a/tests/core/datablocks/test_propertyblock.py b/tests/core/datablocks/test_propertyblock.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/io_/read/test_mrc.py b/tests/io_/read/test_mrc.py
deleted file mode 100644
index 0f9b9724..00000000
--- a/tests/io_/read/test_mrc.py
+++ /dev/null
@@ -1,14 +0,0 @@
-import numpy as np
-import mrcfile
-
-from peepingtom.io_.read.mrc import read_mrc
-from peepingtom.core import ImageBlock
-
-
-def test_read_mrc(tmp_path):
-    file_path = tmp_path / 'TS_00.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)
-    assert imageblock.name == 'TS_00'
diff --git a/tests/peeper/test_depictors/test_images.py b/tests/peeper/test_depictors/test_images.py
deleted file mode 100644
index 817bb520..00000000
--- a/tests/peeper/test_depictors/test_images.py
+++ /dev/null
@@ -1,12 +0,0 @@
-from napari.layers import Image
-from peepingtom.peeper import ImageDepictor
-
-from ...test_data.blocks import imageblock
-
-
-def test_image_depictor():
-    image_depictor = ImageDepictor(imageblock, peeper=None)
-    assert image_depictor.datablock is imageblock
-    image_depictor.init_layers()
-    assert len(image_depictor.layers) == 1
-    assert isinstance(image_depictor.layers[0], Image)
diff --git a/tests/peeper/test_depictors/test_lines.py b/tests/peeper/test_depictors/test_lines.py
deleted file mode 100644
index 90a59ae2..00000000
--- a/tests/peeper/test_depictors/test_lines.py
+++ /dev/null
@@ -1,13 +0,0 @@
-from napari.layers import Shapes, Points
-from peepingtom.peeper import LineDepictor
-
-from ...test_data.blocks import lineblock
-
-
-def test_line_depictor():
-    line_depictor = LineDepictor(lineblock, peeper=None)
-    assert line_depictor.datablock is lineblock
-    line_depictor.init_layers()
-    assert len(line_depictor.layers) == 2
-    assert isinstance(line_depictor.points_layer, Points)
-    assert isinstance(line_depictor.backbone_layer, Shapes)
diff --git a/tests/peeper/test_depictors/test_particles.py b/tests/peeper/test_depictors/test_particles.py
deleted file mode 100644
index 19ecd21b..00000000
--- a/tests/peeper/test_depictors/test_particles.py
+++ /dev/null
@@ -1,13 +0,0 @@
-from napari.layers import Vectors, Points
-from peepingtom.peeper import ParticleDepictor
-
-from ...test_data.blocks import particleblock
-
-
-def test_particle_depictor():
-    particle_depictor = ParticleDepictor(particleblock, peeper=None)
-    assert particle_depictor.datablock is particleblock
-    particle_depictor.init_layers()
-    assert len(particle_depictor.layers) == 2
-    assert isinstance(particle_depictor.point_layer, Points)
-    assert isinstance(particle_depictor.vector_layer, Vectors)
diff --git a/tests/peeper/test_depictors/test_points.py b/tests/peeper/test_depictors/test_points.py
deleted file mode 100644
index 5eb99a13..00000000
--- a/tests/peeper/test_depictors/test_points.py
+++ /dev/null
@@ -1,12 +0,0 @@
-from napari.layers import Points
-from peepingtom.peeper import PointDepictor
-
-from ...test_data.blocks import pointblock
-
-
-def test_points_depictor():
-    point_depictor = PointDepictor(pointblock, peeper=None)
-    assert point_depictor.datablock is pointblock
-    point_depictor.init_layers()
-    assert len(point_depictor.layers) == 1
-    assert isinstance(point_depictor.layers[0], Points)
diff --git a/tests/test_data/blocks.py b/tests/test_data/blocks.py
deleted file mode 100644
index 52b1e515..00000000
--- a/tests/test_data/blocks.py
+++ /dev/null
@@ -1,53 +0,0 @@
-"""
-example DataBlocks of each type
-"""
-import numpy as np
-
-from peepingtom.core import PointBlock, LineBlock, OrientationBlock, ImageBlock, PropertyBlock, ParticleBlock
-from peepingtom.utils import align_vectors
-
-# points
-n = 100
-
-x = np.random.normal(0, 30, n)
-y = np.random.normal(0, 30, n)
-z = np.random.normal(0, 50, n)
-
-xyz = np.column_stack([x, y, z])
-
-pointblock = PointBlock(xyz)
-
-# line
-v = np.linspace(0.5, 5 * np.pi, n)
-x = np.sin(v)
-y = np.cos(v)
-
-xyz = np.column_stack([x, y, v])
-
-lineblock = LineBlock(xyz)
-
-# orientations
-n = 100
-v = np.linspace(0.5, 2 * np.pi, n)
-x = np.sin(v)
-y = np.cos(v)
-z = np.zeros(n)
-
-xyz = np.column_stack([x, y, z])
-unit_z = np.asarray([0, 0, 1])
-rotation_matrices = align_vectors(unit_z, xyz)
-
-orientationblock = OrientationBlock(rotation_matrices)
-
-# properties
-n = 100
-p_dict = {'p1': np.ones((n))}
-
-propertyblock = PropertyBlock(p_dict)
-
-# particles
-particleblock = ParticleBlock(pointblock, orientationblock, propertyblock)
-
-# image
-img = np.ones((10, 10, 10))
-imageblock = ImageBlock(img)
diff --git a/tests/test_data/generator_scripts/__init__.py b/tests/test_data/generator_scripts/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/test_data/generator_scripts/constants.py b/tests/test_data/generator_scripts/constants.py
deleted file mode 100644
index 92072e31..00000000
--- a/tests/test_data/generator_scripts/constants.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from pathlib import Path
-
-test_data_dir = Path('..')
diff --git a/tests/test_data/generator_scripts/simple_3d_star_file.py b/tests/test_data/generator_scripts/simple_3d_star_file.py
deleted file mode 100644
index 643ce587..00000000
--- a/tests/test_data/generator_scripts/simple_3d_star_file.py
+++ /dev/null
@@ -1,48 +0,0 @@
-import numpy as np
-import pandas as pd
-import starfile
-
-from peepingtom.utils.helpers.eulerangle_helper import EulerAngleHelper
-from peepingtom.utils.helpers.linalg_helper import align_vectors
-
-from .constants import test_data_dir
-
-# relion star file simple
-# coords for helix along z
-z = np.linspace(0.1, 6 * np.pi + 0.1, 50)
-x = 3 * np.sin(z)
-y = 3 * np.cos(z)
-
-# backbone along center of helix
-x_backbone = np.zeros(z.shape[0])
-y_backbone = np.zeros(z.shape[0])
-
-xyz = np.column_stack([x, y, z])
-xyz_backbone = np.column_stack([x_backbone, y_backbone, z])
-
-# calculate directions from backbone to points on helix
-orientation_vectors = xyz - xyz_backbone
-orientation_vectors_norm = orientation_vectors / np.linalg.norm(orientation_vectors, axis=1).reshape((-1, 1))
-unit_z = np.array([0, 0, 1])
-
-# calculate rotation matrices to align unit vector to direction away from backbone
-rotation_matrices = align_vectors(unit_z, orientation_vectors_norm)
-
-# calculate eulers from rotation matrices
-euler_angles_rln = EulerAngleHelper(rotation_matrices=rotation_matrices).matrix2euler('relion')
-
-rot = euler_angles_rln[:, 0]
-tilt = euler_angles_rln[:, 1]
-psi = euler_angles_rln[:, 2]
-
-star_info = {'rlnCoordinateX': x,
-             'rlnCoordinateY': y,
-             'rlnCoordinateZ': z,
-             'rlnAngleRot': rot,
-             'rlnAngleTilt': tilt,
-             'rlnAnglePsi': psi}
-
-star = pd.DataFrame.from_dict(star_info)
-star['rlnMicrographName'] = 'test_tomo'
-star_path = test_data_dir / 'relion_3d_simple.star'
-starfile.write(star, star_path)