Repository: brainglobe/brainglobe-heatmap (language: python)

Issue:
Rename package to `brainglobe-heatmap(s)`
Hey, thanks for the software, it's awesome!

I just wanted to let you know that on my side, after ` pip install bgheatmap` and a successful installation, I still could not import the package, getting the error:

`ModuleNotFoundError: No module named 'bgheatmap'`

looking in the site-packages folder of the conda env that I'm using I noticed that the package there is actually named `bgheatmaps` with the final s. In fact, `import bgheatmaps as bgh` works great.

Maybe I'm missing something absolutely trivial, but I found it to be a bit misleading

---
- win10
- clean conda environment with just brainrender and allensdk pip-installed

Use infinite planes for slicing
Before submitting a pull request (PR), please read the [contributing guide](https://github.com/brainglobe/.github/blob/main/CONTRIBUTING.md).

## Description

**What is this PR**

- [x] Bug fix
- [ ] Addition of a new feature
- [ ] Other

**Why is this PR needed?**
fixes a problem when intersecting some planes with the atlases.
The following code crashes without this PR:
```python
import brainrender as br
import bgheatmaps as bgh
import numpy as np

atlas = br.Atlas(atlas_name="allen_mouse_25um")
root = atlas.get_region("root", alpha=1, color=None)
major_divisions = ["Isocortex", "OLF", "HPF", "CTXsp", "STR", "PAL", "TH", "HY", "MB", "P", "MY", "CB"]
regions_meshes = atlas.get_region(*major_divisions, alpha=1, color=None)
if not isinstance(regions_meshes, list):
    regions_meshes = [regions_meshes]

for actor in (root, *regions_meshes):
    # from Render._prepare_actor()
    mtx = [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, -1, 0], [0, 0, 0, 1]]
    actor._mesh = actor.mesh.clone()
    actor._mesh.applyTransform(mtx) # actor._mesh.apply_transform(mtx) in newer vedo
    actor._is_transformed = True

regions_meshes = [r for r in regions_meshes if r.br_class == "brain region"]

s = bgh.slicer.Slicer(6000, "frontal", 100, root)
# CRASHES
print(s.get_structures_slice_coords(regions_meshes, root))
```

**What does this PR do?**
Instead of using a mesh as a plane, this PR uses a proper infinite plane for slicing the atlas. This effectively bypasses an open issue of vtk regarding the intersection of two meshes. See: https://discourse.vtk.org/t/vtkintersectionpolydatafilter-crashing/9428
As a bonus it is conceptually a "better" way to represent the planes.
In order to do so, I had to use a vtk function by backporting a binding from `vedo=2023.4.6`.

## References
#11

## How has this PR been tested?
I ran all examples from this repository + extensive usage in my work project.
Additionally, I made sure every combination of `(frontal, horizontal, sagittal)` orientations and `(allen_cord_20um_v1.0,  allen_mouse_25um_v1.2,  mpin_zfish_1um_v1.0)` altases had their plane's normal vector as well as `u` and `v` (`x` and `y` in 2D projections, respectively) in the correct direction by using the modified `planner`.

## Is this a breaking change?
No, it should keep all the previous APIs and functionalities.

## Does this PR require an update to the documentation?
No

## Checklist:

- [x] The code has been tested locally
- [ ] Tests have been added to cover all new functionality (unit & integration)
- [ ] The documentation has been updated to reflect any changes
- [ ] The code has been formatted with [pre-commit](https://pre-commit.com/)

fix a bug with heatmap's colormap when values contained NaN
Before submitting a pull request (PR), please read the [contributing guide](https://github.com/brainglobe/.github/blob/main/CONTRIBUTING.md).

## Description

**What is this PR**

Bug fix

I know this project is momentarily unmaintained. For this reason i don't expect this PR to be merged any time soon.
In the meantime, I prefer submitting it.

**Why is this PR needed?**

with the following values, the color mapping is wrong:
```python
import vedo as vd
import bgheatmaps as bgh
import numpy as np

vd.embedWindow("k3d")

values = {'MO': np.nan, 'RHP': 5, 'AUD': -5}    # does NOT work -> uses cmin=np.nan
# values = {'RHP': 5, 'MO': np.nan, 'AUD': -5}  # does NOT work -> uses cmin=-5
# values = {'RHP': 5, 'AUD': -5, 'MO': np.nan}  # does NOT work -> uses cmin=-5


h = bgh.heatmap(
    values,
    position=5000,  # when using a named orientation you can pass a single value!
    orientation="frontal",  # 'frontal' or 'sagittal', or 'horizontal' or a tuple (x,y,z)
    title="horizontal view",
    vmin=0,
    vmax=3,
    format="2D",
)
h.show()
```

**What does this PR do?**
checks for `nan` values and allows users to set `cmin=0` or `cmax=0`

## How has this PR been tested?

with the above mentioned code

## Is this a breaking change?

no

## Does this PR require an update to the documentation?

no



Discussion / hints:
Hey thanks for highlighting this, likely a typo during package creation. I don't think it's worth creating a new release, hopefully it won't be too confusing. I'll leave this issue up so that people can see it if they also get confused
I think maybe it is worth, it sounds like it's likely to create some headaches!



Fix patch:
diff --git a/.gitignore b/.gitignore
index f823c46..aedc8d7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,9 +1,3 @@
-.chache
-workspace.py
-*.mp4
-.vscode
-.vscode/*
-
 # Byte-compiled / optimized / DLL files
 __pycache__/
 *.py[cod]
@@ -14,6 +8,7 @@ __pycache__/
 
 # Distribution / packaging
 .Python
+env/
 build/
 develop-eggs/
 dist/
@@ -25,12 +20,9 @@ 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
@@ -45,17 +37,13 @@ pip-delete-this-directory.txt
 # Unit test / coverage reports
 htmlcov/
 .tox/
-.nox/
 .coverage
 .coverage.*
 .cache
 nosetests.xml
 coverage.xml
-*.cover
-*.py,cover
+*,cover
 .hypothesis/
-.pytest_cache/
-cover/
 
 # Translations
 *.mo
@@ -64,83 +52,32 @@ cover/
 # Django stuff:
 *.log
 local_settings.py
-db.sqlite3
-db.sqlite3-journal
 
-# Flask stuff:
+# Flask instance folder
 instance/
-.webassets-cache
-
-# Scrapy stuff:
-.scrapy
 
 # Sphinx documentation
 docs/_build/
 
+# MkDocs documentation
+/site/
+
 # 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/
+# Pycharm and VSCode
+.idea/
 venv/
-ENV/
-env.bak/
-venv.bak/
-
-# Spyder project settings
-.spyderproject
-.spyproject
-
-# Rope project settings
-.ropeproject
-
-# mkdocs documentation
-/site
+.vscode/
 
-# mypy
-.mypy_cache/
-.dmypy.json
-dmypy.json
-
-# Pyre type checker
-.pyre/
+# IPython Notebook
+.ipynb_checkpoints
 
-# pytype static type analyzer
-.pytype/
+# pyenv
+.python-version
 
-# Cython debug symbols
-cython_debug/
+# OS
+.DS_Store
 
-.idea/*
\ No newline at end of file
+# written by setuptools_scm
+**/_version.py
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index e919ad2..63665e0 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,17 +1,39 @@
+
+
+# Configuring https://pre-commit.ci/
+ci:
+    autoupdate_schedule: monthly
+
 repos:
--   repo: https://github.com/psf/black
-    rev: 23.11.0
-    hooks:
-    - id: black
-      pass_filenames: true
--   repo: https://github.com/PyCQA/flake8
-    rev: 6.1.0
-    hooks:
-    - id: flake8
-      pass_filenames: true
-      # this seems to need to be here in addition to setup.cfg
-      exclude: __init__.py, workspace.py
--   repo: https://github.com/pre-commit/mirrors-mypy
-    rev: v1.7.0
-    hooks:
-    -   id: mypy
\ No newline at end of file
+    - repo: https://github.com/pre-commit/pre-commit-hooks
+      rev: v4.4.0
+      hooks:
+          - id: check-docstring-first
+          - id: check-executables-have-shebangs
+          - id: check-merge-conflict
+          - id: check-toml
+          - id: end-of-file-fixer
+          - id: mixed-line-ending
+            args: [--fix=lf]
+          - id: requirements-txt-fixer
+          - id: trailing-whitespace
+    - repo: https://github.com/charliermarsh/ruff-pre-commit
+      rev: v0.0.272
+      hooks:
+        - id: ruff
+    - repo: https://github.com/psf/black
+      rev: 23.3.0
+      hooks:
+          - id: black
+    - repo: https://github.com/pre-commit/mirrors-mypy
+      rev: v1.3.0
+      hooks:
+          - id: mypy
+            additional_dependencies:
+                - types-setuptools
+    - repo: https://github.com/mgedmin/check-manifest
+      rev: "0.49"
+      hooks:
+          - id: check-manifest
+            args: [--no-build-isolation]
+            additional_dependencies: [setuptools-scm]
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644
index 0000000..2c1a783
--- /dev/null
+++ b/MANIFEST.in
@@ -0,0 +1,10 @@
+include LICENSE
+include README.md
+exclude .pre-commit-config.yaml
+
+recursive-exclude * __pycache__
+recursive-exclude * *.py[co]
+recursive-exclude docs *
+recursive-exclude tests *
+recursive-exclude examples *
+recursive-exclude images *
diff --git a/README.md b/README.md
index e034250..92ac28b 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,5 @@
 # BG Heatmaps
-Rendering heatmaps with brainrender.
+Rendering anatomical heatmaps with brainrender and matplotlib.
 
 
 
@@ -20,7 +20,7 @@ Rendering heatmaps with brainrender.
 The starting point for a heatmap visualization is a `dict` assigning scalar values to a set of brain regions (identified by their acronym).
 For example:
 
-```python 
+```python
     regions = dict(  # scalar values for each region
         TH=1,
         RSP=0.2,
@@ -63,7 +63,7 @@ planner = bgh.plan(
         8000,
         5000,
         5000,
-    ),  
+    ),
     orientation="frontal",  # orientation, or 'sagittal', or 'horizontal' or a tuple (x,y,z)
     thickness=2000,  # thickness of the slices used for rendering (in microns)
 )
@@ -109,7 +109,7 @@ bgh.heatmap(
         8000,
         5000,
         5000,
-    ),  
+    ),
     orientation="horizontal",  # 'frontal' or 'sagittal', or 'horizontal' or a tuple (x,y,z)
     title="horizontal view",
     vmin=-5,
@@ -136,14 +136,14 @@ coordinates = bgh.get_plane_coordinates(
         8000,
         5000,
         5000,
-    ),  
+    ),
     orientation="frontal",  # 'frontal' or 'sagittal', or 'horizontal' or a tuple (x,y,z)
 )
 ```
 
 ## Using `bgheatmap` with other atlases.
 
-`bgheatmap` uses `brainrender` which, in turn, uses brainglobe's `Atlas API` under the hood. That means that all of `bgheatmap`'s functionality is compatible with any of the atlases supported by the atlas API. 
+`bgheatmap` uses `brainrender` which, in turn, uses brainglobe's `Atlas API` under the hood. That means that all of `bgheatmap`'s functionality is compatible with any of the atlases supported by the atlas API.
 `bgh.heatmap`, `bgh.planner` and `bgh.get_plane_coordinates` all accept a `atlas_name` argument, pass the name of the atlas name you'd like to use!
 For more information see the API's [https://docs.brainglobe.info/bg-atlasapi/introduction](documentation).
 
diff --git a/bgheatmaps/__init__.py b/bgheatmaps/__init__.py
deleted file mode 100644
index af023a0..0000000
--- a/bgheatmaps/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from bgheatmaps.heatmaps import heatmap
-from bgheatmaps.planner import plan
-from bgheatmaps.slicer import get_structures_slice_coords
diff --git a/brainglobe_heatmap/__init__.py b/brainglobe_heatmap/__init__.py
new file mode 100644
index 0000000..64a69c2
--- /dev/null
+++ b/brainglobe_heatmap/__init__.py
@@ -0,0 +1,11 @@
+from importlib.metadata import PackageNotFoundError, version
+
+try:
+    __version__ = version("brainglobe-heatmap")
+except PackageNotFoundError:
+    # package is not installed
+    pass
+
+from brainglobe_heatmap.heatmaps import heatmap
+from brainglobe_heatmap.planner import plan
+from brainglobe_heatmap.slicer import get_structures_slice_coords
diff --git a/bgheatmaps/heatmaps.py b/brainglobe_heatmap/heatmaps.py
similarity index 88%
rename from bgheatmaps/heatmaps.py
rename to brainglobe_heatmap/heatmaps.py
index 904dee2..eac41df 100644
--- a/bgheatmaps/heatmaps.py
+++ b/brainglobe_heatmap/heatmaps.py
@@ -1,17 +1,15 @@
-from vedo.colors import colorMap as map_color
-from typing import Optional, Union, Dict, Tuple
-import numpy as np
+from typing import Dict, Optional, Tuple, Union
+
+import matplotlib as mpl
 import matplotlib.pyplot as plt
+import numpy as np
+from brainrender import Scene, cameras, settings
+from brainrender.atlas import Atlas
 from mpl_toolkits.axes_grid1 import make_axes_locatable
-import matplotlib as mpl
-
 from myterial import grey_darker
+from vedo.colors import color_map as map_color
 
-from brainrender import Scene
-from brainrender import settings, cameras
-from brainrender.atlas import Atlas
-
-from bgheatmaps.slicer import Slicer
+from brainglobe_heatmap.slicer import Slicer
 
 # Set settings for heatmap visualization
 settings.SHOW_AXES = False
@@ -20,9 +18,11 @@
 settings.ROOT_COLOR = grey_darker
 
 # Set settings for transparent background
+# vedo for transparent bg
+# settings.vsettings.screenshotTransparentBackground = True
 
-# settings.vsettings.screenshotTransparentBackground = True  # vedo for transparent bg
-# settings.vsettings.useFXAA = False  # This needs to be false for transparent bg
+# This needs to be false for transparent bg
+# settings.vsettings.useFXAA = False
 
 
 def check_values(values: dict, atlas: Atlas) -> Tuple[float, float]:
@@ -34,13 +34,17 @@ def check_values(values: dict, atlas: Atlas) -> Tuple[float, float]:
     for k, v in values.items():
         if not isinstance(v, (float, int)):
             raise ValueError(
-                f'Heatmap values should be floats, not: {type(v)} for entry "{k}"'
+                f"Heatmap values should be floats, "
+                f'not: {type(v)} for entry "{k}"'
             )
 
         if k not in atlas.lookup_df.acronym.values:
             raise ValueError(f'Region name "{k}" not recognized')
 
-    vmax, vmin = max(values.values()), min(values.values())
+    not_nan = [v for v in values.values() if not np.isnan(v)]
+    if len(not_nan) == 0:
+        return np.nan, np.nan
+    vmax, vmin = max(not_nan), min(not_nan)
     return vmax, vmin
 
 
@@ -108,8 +112,8 @@ def prepare_colors(
         if _vmax == _vmin:
             _vmin = _vmax * 0.5
 
-        vmin = vmin or _vmin
-        vmax = vmax or _vmax
+        vmin = vmin if vmin == 0 or vmin else _vmin
+        vmax = vmax if vmax == 0 or vmax else _vmax
         self.vmin, self.vmax = vmin, vmax
 
         self.colors = {
@@ -171,14 +175,13 @@ def plot(
         xlabel: str = "μm",
         ylabel: str = "μm",
         hide_axes: bool = False,
-        filename: str = None,
-        cbar_label: str = None,
+        filename: Optional[str] = None,
+        cbar_label: Optional[str] = None,
         **kwargs,
     ) -> plt.Figure:
         """
         Plots the heatmap in 2D using matplotlib
         """
-        self.scene.close()
         projected, _ = self.slicer.get_structures_slice_coords(
             self.regions_meshes, self.scene.root
         )
@@ -273,12 +276,14 @@ def plot(
     heatmap(
         values,
         position=None,
+        # or 'sagittal', or 'horizontal' or a tuple (x,y,z)
         orientation=(
             1,
             1,
             0,
-        ),  # or 'sagittal', or 'horizontal' or a tuple (x,y,z)
-        thickness=250,  # thickness of the slices used for rendering (in microns)
+        ),
+        # thickness of the slices used for rendering (in microns)
+        thickness=250,
         title="frontal",
         vmin=-5,
         vmax=3,
diff --git a/brainglobe_heatmap/plane.py b/brainglobe_heatmap/plane.py
new file mode 100644
index 0000000..d898459
--- /dev/null
+++ b/brainglobe_heatmap/plane.py
@@ -0,0 +1,119 @@
+from typing import Dict, List
+
+import numpy as np
+import vedo as vd
+import vtkmodules.all as vtk
+from brainrender.actor import Actor
+from vtkmodules.vtkFiltersCore import vtkPolyDataPlaneCutter
+
+np.float = float  # for compatibility with old vedo
+vtk.vtkLogger.SetStderrVerbosity(vtk.vtkLogger.VERBOSITY_OFF)
+
+
+# from vedo 2023.4.6
+def intersect_with_plane(mesh: vd.Mesh, origin=(0, 0, 0), normal=(1, 0, 0)):
+    """
+    Intersect this Mesh with a plane to return a set of lines.
+
+    Example:
+        ```python
+        from vedo import *
+        sph = Sphere()
+        mi = sph.clone().intersect_with_plane().join()
+        print(mi.lines())
+        show(sph, mi, axes=1).close()
+        ```
+        ![](https://vedo.embl.es/images/feats/intersect_plane.png)
+    """
+    plane = vtk.vtkPlane()
+    plane.SetOrigin(origin)
+    plane.SetNormal(normal)
+
+    cutter = vtkPolyDataPlaneCutter()
+    cutter.SetInputData(mesh.dataset)
+    cutter.SetPlane(plane)
+    cutter.InterpolateAttributesOn()
+    cutter.ComputeNormalsOff()
+    cutter.Update()
+
+    msh = vd.Mesh(cutter.GetOutput(), "k", 1).lighting("off")
+    msh.properties.SetLineWidth(3)
+    msh.name = "PlaneIntersection"
+    return msh
+
+
+class Plane:
+    def __init__(
+        self, origin: np.ndarray, u: np.ndarray, v: np.ndarray
+    ) -> None:
+        self.center = origin
+        self.u = u / np.linalg.norm(u)
+        self.v = v / np.linalg.norm(v)
+        assert np.isclose(np.dot(self.u, self.v), 0), (
+            f"The plane vectors must be orthonormal to each "
+            f"other (u ⋅ v = {np.dot(self.u, self.v)})"
+        )
+        self.normal = np.cross(self.u, self.v)
+        self.M = np.vstack([u, v]).T
+
+    @staticmethod
+    def from_norm(origin: np.ndarray, norm: np.ndarray) -> vd.Plane:
+        u = np.zeros(3)
+        m = np.where(norm != 0)[0][0]  # orientation can't be all-zeros
+        n = (m + 1) % 3
+        u[n] = norm[m]
+        u[m] = -norm[n]
+        norm = norm / np.linalg.norm(norm)
+        u = u / np.linalg.norm(u)
+        v = np.cross(norm, u)
+        return Plane(origin, u, v)
+
+    def to_mesh(self, actor: Actor):
+        bounds = actor.bounds()
+        length = max(
+            bounds[1] - bounds[0],
+            bounds[3] - bounds[2],
+            bounds[5] - bounds[4],
+        )
+        length += length / 3
+
+        plane_mesh = Actor(
+            vd.Plane(pos=self.center, normal=self.normal, s=(length, length)),
+            name=f"PlaneMesh at {self.center} norm: {self.normal}",
+            br_class="plane_mesh",
+        )
+        plane_mesh.width = length
+        return plane_mesh
+
+    def centerOfMass(self):
+        return self.center
+
+    def P3toP2(self, ps):
+        # ps is a list of 3D points
+        # returns a list of 2D point mapped on
+        # the plane (u -> x axis, v -> y axis)
+        return (ps - self.center) @ self.M
+
+    def intersectWith(self, mesh: vd.Mesh):
+        # mesh.intersect_with_plane(
+        # origin=self.center, normal=self.normal) in newer vedo
+        return intersect_with_plane(
+            mesh, origin=self.center, normal=self.normal
+        )
+
+    # for Slicer.get_structures_slice_coords()
+    def get_projections(self, actors: List[Actor]) -> Dict[str, np.ndarray]:
+        projected = {}
+        for actor in actors:
+            mesh: vd.Mesh = actor._mesh
+            intersection = self.intersectWith(mesh)
+            if not intersection.vertices.shape[0]:
+                continue
+            pieces = intersection.split()  # intersection.split() in newer vedo
+            for piece_n, piece in enumerate(pieces):
+                # sort coordinates
+                points = piece.join(reset=True).vertices
+                projected[actor.name + f"_segment_{piece_n}"] = self.P3toP2(
+                    points
+                )
+        return projected
diff --git a/bgheatmaps/planner.py b/brainglobe_heatmap/planner.py
similarity index 65%
rename from bgheatmaps/planner.py
rename to brainglobe_heatmap/planner.py
index 92f8398..e455b81 100644
--- a/bgheatmaps/planner.py
+++ b/brainglobe_heatmap/planner.py
@@ -1,15 +1,23 @@
 from typing import Union
+
 import numpy as np
-from vedo import Arrow, Plane, Sphere
-from rich.table import Table
-from rich.panel import Panel
+from brainrender import settings
+from myterial import (
+    amber_lighter,
+    blue_dark,
+    green_dark,
+    grey,
+    orange,
+    pink_dark,
+    red_dark,
+)
 from rich import print
+from rich.panel import Panel
+from rich.table import Table
+from vedo import Arrow, Sphere
 
-from myterial import pink_dark, blue_dark, amber_lighter, grey, amber, orange
-
-from bgheatmaps.heatmaps import heatmap
-
-from brainrender import settings
+from brainglobe_heatmap.heatmaps import heatmap
+from brainglobe_heatmap.plane import Plane
 
 settings.BACKGROUND_COLOR = amber_lighter
 settings.ROOT_COLOR = grey
@@ -23,21 +31,14 @@ def print_plane(name: str, plane: Plane, color: str):
     def fmt_array(x: np.ndarray) -> str:
         return str(tuple([round(v, 2) for v in x]))
 
-    # create a table to display the vertices posittion
-    vert_tb = Table(box=None)
-    vert_tb.add_column(style=f"{amber}", justify="right")
-    vert_tb.add_column(style="white")
-
-    for i in range(4):
-        vert_tb.add_row(f"({i})", fmt_array(plane.mesh.points()[i]))
-
     tb = Table(box=None)
     tb.add_column(style=f"bold {orange}", justify="right")
     tb.add_column(style="white")
 
-    tb.add_row("center point: ", fmt_array(plane.mesh.center))
-    tb.add_row("norm: ", str(tuple(plane.mesh.normal)))
-    tb.add_row("Vertices: ", vert_tb)
+    tb.add_row("center point: ", fmt_array(plane.center))
+    tb.add_row("norm: ", str(tuple(plane.normal)))
+    tb.add_row("u: ", str(tuple(plane.u)))
+    tb.add_row("v: ", str(tuple(plane.v)))
 
     print(
         Panel.fit(
@@ -91,21 +92,25 @@ def show(self):
             (blue_dark, pink_dark),
             (0.8, 0.3),
         ):
-            plane.alpha(alpha).color(color)
-
-            self.scene.add(plane, transform=False)
-            self.scene.add(
-                Arrow(
-                    plane.center,
-                    np.array(plane.center)
-                    + np.array(plane.mesh.normal) * self.arrow_scale,
-                    c=color,
-                ),
-                transform=False,
-            )
+            plane_mesh = plane.to_mesh(self.scene.root)
+            plane_mesh.alpha(alpha).color(color)
+
+            self.scene.add(plane_mesh, transform=False)
+            for vector, v_color in zip(
+                (plane.normal, plane.u, plane.v), (color, red_dark, green_dark)
+            ):
+                self.scene.add(
+                    Arrow(
+                        plane.center,
+                        np.array(plane.center)
+                        + np.array(vector) * self.arrow_scale,
+                        c=v_color,
+                    ),
+                    transform=False,
+                )
 
             self.scene.add(
-                Sphere(plane.center, r=plane.width / 125, c="k"),
+                Sphere(plane_mesh.center, r=plane_mesh.width / 125, c="k"),
                 transform=False,
             )
 
@@ -134,12 +139,15 @@ def show(self):
 
     plan(
         regions,
+        # position of the center point of the plane
         position=(
             8000,
             5000,
             5000,
-        ),  # position of the center point of the plane
-        orientation="frontal",  # or 'sagittal', or 'horizontal' or a tuple (x,y,z)
-        thickness=2000,  # thickness of the slices used for rendering (in microns)
+        ),
+        # or 'sagittal', or 'horizontal' or a tuple (x,y,z)
+        orientation="frontal",
+        # thickness of the slices used for rendering (in microns)
+        thickness=2000,
         arrow_scale=750,
     ).show()
diff --git a/bgheatmaps/slicer.py b/brainglobe_heatmap/slicer.py
similarity index 64%
rename from bgheatmaps/slicer.py
rename to brainglobe_heatmap/slicer.py
index 17b4ed0..b99e1b9 100644
--- a/bgheatmaps/slicer.py
+++ b/brainglobe_heatmap/slicer.py
@@ -1,10 +1,11 @@
-from typing import List, Dict, Union, Optional
-from vedo import Plane
-import numpy as np
+from typing import Dict, List, Optional, Union
 
+import numpy as np
 from brainrender.actor import Actor
 from brainrender.scene import Scene
 
+from brainglobe_heatmap.plane import Plane
+
 
 def get_ax_idx(orientation: str) -> int:
     """
@@ -30,7 +31,8 @@ def __init__(
         root: Actor,
     ):
         """
-        Computes the position of two planes given a point (position) and an orientation (named orientation or
+        Computes the position of two planes given a point (position) and an
+        orientation (named orientation or
         3D vector) + thickness (spacing between the two planes)
         """
         if position is None:
@@ -39,11 +41,13 @@ def __init__(
         if isinstance(position, (float, int)):
             if isinstance(orientation, str):
                 pval = position
-                position = root.centerOfMass()
+                position = root.center_of_mass()
                 position[get_ax_idx(orientation)] = pval
             else:
                 raise ValueError(
-                    "When a single float value is passed for position, the orientation should be one of the named orientations values"
+                    "When a single float value is passed for "
+                    "position, the orientation "
+                    "should be one of the named orientations values"
                 )
 
         position = np.array(position)
@@ -58,71 +62,48 @@ def __init__(
             p1 = position - shift
 
             # get the two planes
-            norm0, norm1 = np.zeros(3), np.zeros(3)
-            norm0[axidx] = 1
-            norm1[axidx] = -1
+            # assures that u0×v0 is all-positive -> it's for plane0
+            if orientation == "frontal":
+                u0, v0 = np.array([[0, 0, -1], [0, 1, 0]])
+            elif orientation == "sagittal":
+                u0, v0 = np.array([[1, 0, 0], [0, 1, 0]])
+            else:  # orientation == "horizontal"
+                u0, v0 = np.array([[0, 0, -1], [-1, 0, 0]])
+            plane0 = Plane(position, u0, v0)
+            u1, v1 = u0.copy(), -v0.copy()  # set u1:=u0 and v1:=-v0
+            plane1 = Plane(p1, u1, v1)
         else:
             orientation = np.array(orientation)
 
             p1 = position + orientation * thickness  # type: ignore
 
             norm0 = orientation  # type: ignore
+            plane0 = Plane.from_norm(position, norm0)
             norm1 = -orientation  # type: ignore
-
-        # get the length of the largest dimension of the atlas
-        bounds = root.bounds()
-        length = max(
-            bounds[1] - bounds[0],
-            bounds[3] - bounds[2],
-            bounds[5] - bounds[4],
-        )
-        length += length / 3
+            plane1 = Plane.from_norm(p1, norm1)
 
         self.plane0 = Actor(
-            Plane(pos=position, normal=norm0, sx=length, sy=length),
-            name=f"Plane at {position} norm: {norm0}",
+            plane0,
+            name=f"Plane at {plane0.center} norm: {plane0.normal}",
             br_class="plane",
         )
-        self.plane0.width = length
-
         self.plane1 = Actor(
-            Plane(pos=p1, normal=norm1, sx=length, sy=length),
-            name=f"Plane at {p1} norm: {norm1}",
+            plane1,
+            name=f"Plane at {plane1.center} norm: {plane1.normal}",
             br_class="plane",
         )
-        self.plane1.width = length
 
     def get_structures_slice_coords(self, regions: List[Actor], root: Actor):
         """
         It computes the intersection between the first slice plane and all
         user given brain regions,
-        returning the coordinates of each region as a set of XY (i.e. in the plane's
+        returning the coordinates of each region as a
+        set of XY (i.e., in the plane's
         coordinates system) coordinates
         """
         regions = regions + [root]
 
-        pts = self.plane0.points() - self.plane0.points()[0]
-        v = pts[1] / np.linalg.norm(pts[1])
-        w = pts[2] / np.linalg.norm(pts[2])
-
-        M = np.vstack([v, w]).T  # 3 x 2
-
-        projected: Dict[str, np.ndarray] = {}
-        for n, actor in enumerate(regions):
-            # get region/plane intersection
-            intersection = self.plane0.intersectWith(
-                actor._mesh.triangulate()
-            )  # points: (N x 3)
-
-            if not intersection.points().shape[0]:
-                continue  # no intersection
-
-            pieces = intersection.splitByConnectivity()
-            for piece_n, piece in enumerate(pieces):
-                # sort coordinates
-                points = piece.join(reset=True).points()
-
-                projected[actor.name + f"_segment_{piece_n}"] = points @ M
+        projected: Dict[str, np.ndarray] = self.plane0.get_projections(regions)
 
         # get output coordinates
         coordinates: Dict[str, List[np.ndarray]] = dict()
@@ -138,13 +119,13 @@ def show_plane_intersection(
         self, scene: Scene, regions: List[Actor], root: Actor
     ):
         """
-        Slices regions' meshjes with plane0 and adds the resulting intersection
+        Slices regions' meshes with plane0 and adds the resulting intersection
         to the brainrender scene.
         """
         for region in regions + [root]:
             intersection = self.plane0.intersectWith(region._mesh)
 
-            if len(intersection.points()):
+            if len(intersection.vertices):
                 scene.add(intersection, transform=False)
 
             if region.name != "root":
@@ -168,8 +149,9 @@ def get_structures_slice_coords(
     atlas_name: Optional[str] = None,
 ) -> Dict[str, List[np.ndarray]]:
     """
-    Given a list of regions name and a set of plane parameters, it returns
-    the coordinates of the plane/regions' intersection in the plane's coordinates
+    Given a list of region name and a set of plane parameters,
+    it returns the coordinates of the plane/regions'
+    intersection in the plane's coordinates
     """
 
     scene = Scene(atlas_name=atlas_name)
@@ -183,5 +165,4 @@ def get_structures_slice_coords(
     structures_coords = slicer.get_structures_slice_coords(
         regions_actors, scene.root
     )[1]
-    scene.close()
     return structures_coords
diff --git a/examples/cellfinder_cell_density.py b/examples/cellfinder_cell_density.py
index 79902ba..c2f0216 100644
--- a/examples/cellfinder_cell_density.py
+++ b/examples/cellfinder_cell_density.py
@@ -1,14 +1,14 @@
-import pandas as pd
+"""
+    This example shows how to go from cell counts per region
+    (e.g. as outputted by cellfinder) to a plot showing the
+    density (count/volume) of cells in each brain region
+"""
 
+import pandas as pd
 from bg_atlasapi.bg_atlas import BrainGlobeAtlas
 from brainrender._io import load_mesh_from_file
 
-import bgheatmaps as bgh
-
-"""
-    This exmaple shows how to go from cell counts per region (e.g. as outputted by
-    cellfinder) to a plot showing the density (count/volume) of cells in each brain region
-"""
+import brainglobe_heatmap as bgh
 
 # get the number of cells for each region
 data = pd.read_hdf("examples/cell_counts_example.h5")
diff --git a/examples/get_coordiantes.py b/examples/get_coordinates.py
similarity index 68%
rename from examples/get_coordiantes.py
rename to examples/get_coordinates.py
index 9a4a53e..2cce99c 100644
--- a/examples/get_coordiantes.py
+++ b/examples/get_coordinates.py
@@ -1,9 +1,8 @@
-import bgheatmaps as bgh
-
 """
-    This example shows how to get the coordinates 
+    This example shows how to get the coordinates
     of selected brain regions in the slicing plane
 """
+import brainglobe_heatmap as bgh
 
 regions = [
     "TH",
@@ -31,7 +30,8 @@
         5000,
         5000,
     ),
-    orientation="frontal",  # 'frontal' or 'sagittal', or 'horizontal' or a tuple (x,y,z)
+    # 'frontal' or 'sagittal', or 'horizontal' or a tuple (x,y,z)
+    orientation="frontal",
 )
 
 print(coordinates)
diff --git a/examples/heatmap_2d.py b/examples/heatmap_2d.py
index de31e5c..96fb674 100644
--- a/examples/heatmap_2d.py
+++ b/examples/heatmap_2d.py
@@ -1,9 +1,9 @@
-import bgheatmaps as bgh
-
 """
     This example shows how to use visualize a heatmap in 2D
 """
 
+import brainglobe_heatmap as bgh
+
 values = dict(  # scalar values for each region
     TH=1,
     RSP=0.2,
@@ -25,8 +25,10 @@
 
 f = bgh.heatmap(
     values,
-    position=5000,  # when using a named orientation you can pass a single value!
-    orientation="frontal",  # 'frontal' or 'sagittal', or 'horizontal' or a tuple (x,y,z)
+    # when using a named orientation, you can pass a single value!
+    position=5000,
+    # 'frontal' or 'sagittal,' or 'horizontal' or a tuple (x,y,z)
+    orientation="frontal",
     title="horizontal view",
     vmin=-5,
     vmax=3,
diff --git a/examples/heatmap_3d.py b/examples/heatmap_3d.py
index c719db0..fa4a615 100644
--- a/examples/heatmap_3d.py
+++ b/examples/heatmap_3d.py
@@ -1,9 +1,9 @@
-import bgheatmaps as bgh
-
 """
     This example shows how to use visualize a heatmap in 3D
 """
 
+import brainglobe_heatmap as bgh
+
 values = dict(  # scalar values for each region
     TH=1,
     RSP=0.2,
diff --git a/examples/heatmap_spinal_coord.py b/examples/heatmap_spinal_coord.py
index 555c30d..4ffd1ff 100644
--- a/examples/heatmap_spinal_coord.py
+++ b/examples/heatmap_spinal_coord.py
@@ -1,5 +1,4 @@
-import bgheatmaps as bgh
-
+import brainglobe_heatmap as bgh
 
 values = {
     "2Ssp": 3.5,
@@ -23,7 +22,8 @@
 f = bgh.heatmap(
     values,
     position=1000,
-    orientation="sagittal",  # 'frontal' or 'sagittal', or 'horizontal' or a tuple (x,y,z)
+    # 'frontal' or 'sagittal', or 'horizontal' or a tuple (x,y,z)
+    orientation="sagittal",
     thickness=1000,
     atlas_name="allen_cord_20um",
     format="2D",
diff --git a/examples/heatmap_zebrafish.py b/examples/heatmap_zebrafish.py
index e7d2009..36002a3 100644
--- a/examples/heatmap_zebrafish.py
+++ b/examples/heatmap_zebrafish.py
@@ -1,5 +1,4 @@
-import bgheatmaps as bgh
-
+import brainglobe_heatmap as bgh
 
 values = {
     "facial motor nucleus": 3.5,
@@ -24,7 +23,8 @@
 f = bgh.heatmap(
     values,
     position=175,
-    orientation="horizontal",  # 'frontal' or 'sagittal', or 'horizontal' or a tuple (x,y,z)
+    # 'frontal' or 'sagittal', or 'horizontal' or a tuple (x,y,z)
+    orientation="horizontal",
     thickness=250,
     atlas_name="mpin_zfish_1um",
     format="2D",
diff --git a/examples/plan.py b/examples/plan.py
index 7ff14d8..97b558a 100644
--- a/examples/plan.py
+++ b/examples/plan.py
@@ -1,10 +1,10 @@
-import bgheatmaps as bgh
-
 """
-    This example shows how to use the `plan` functionality to visualize the 
+    This example shows how to use the `plan` functionality to visualize the
     position of the planes used to 'slice' the brain for heatmap visualization.
 """
 
+import brainglobe_heatmap as bgh
+
 regions = [
     "TH",
     "RSP",
diff --git a/examples/slicer_2D.py b/examples/slicer_2D.py
index fb0cc68..d58382d 100644
--- a/examples/slicer_2D.py
+++ b/examples/slicer_2D.py
@@ -1,15 +1,14 @@
-import bgheatmaps as bgh
+"""
+    This example shows how to use brainglobe_heatmap
+    to get the contours of 'sliced'
+    brain regions to overlay them over histological data.
+"""
 
-from matplotlib import pyplot as plt
 import matplotlib.patheffects as path_effects
-
 from bg_atlasapi import BrainGlobeAtlas
+from matplotlib import pyplot as plt
 
-
-"""
-    This example shows how to use bgheatmaps to get the contours of 'sliced'
-    brain regions to overlay them over histological data.
-"""
+import brainglobe_heatmap as bgh
 
 atlas_name = "mpin_zfish_1um"  # name of the atlas
 
diff --git a/pyproject.toml b/pyproject.toml
index 7dcafd3..c6bde6f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,20 +1,102 @@
+[project]
+name = "brainglobe-heatmap"
+authors = [{name = "Federico Claudi", email= "hello@brainglobe.info"}]
+description = "Rendering anatomical heatmaps with brainrender and matplotlib"
+readme = "README.md"
+requires-python = ">=3.9.0"
+dynamic = ["version"]
+
+dependencies = ["brainrender", "matplotlib", "numpy", "myterial", "rich"]
+
+license = {text = "MIT"}
+
+classifiers = [
+    "Development Status :: 2 - Pre-Alpha",
+    "Programming Language :: Python",
+    "Programming Language :: Python :: 3",
+    "Programming Language :: Python :: 3.9",
+    "Programming Language :: Python :: 3.10",
+    "Programming Language :: Python :: 3.11",
+    "Operating System :: OS Independent",
+    "License :: OSI Approved :: MIT License",
+]
+
+[project.urls]
+"Homepage" = "https://github.com/brainglobe/brainglobe-heatmap"
+"Bug Tracker" = "https://github.com/brainglobe/brainglobe-heatmap/issues"
+"Documentation" = "https://github.com/brainglobe/brainglobe-heatmap"
+"Source Code" = "https://github.com/brainglobe/brainglobe-heatmap"
+"User Support" = "https://github.com/brainglobe/brainglobe-heatmap/issues"
+
+[project.optional-dependencies]
+dev = [
+  "pytest",
+  "pytest-cov",
+  "coverage",
+  "tox",
+  "black",
+  "mypy",
+  "pre-commit",
+  "ruff",
+  "setuptools_scm",
+]
+
+
+[build-system]
+requires = [
+    "setuptools>=45",
+    "wheel",
+    "setuptools_scm[toml]>=6.2",
+]
+build-backend = "setuptools.build_meta"
+
+[tool.setuptools]
+include-package-data = true
+
+[tool.setuptools.packages.find]
+include = ["brainglobe_heatmap*"]
+exclude = ["tests*"]
+
+[tool.pytest.ini_options]
+addopts = "--cov=brainglobe_heatmap"
+
 [tool.black]
-target-version = ['py36', 'py37', 'py38']
+target-version = ['py39', 'py310', 'py311']
 skip-string-normalization = false
 line-length = 79
-exclude = '''
-(
-  /(
-      \.eggs
-    | \.git
-    | \.hg
-    | \.mypy_cache
-    | \.tox
-    | \.venv
-    | _build
-    | buck-out
-    | build
-    | dist
-  )/
-)
-'''
\ No newline at end of file
+
+[tool.setuptools_scm]
+
+[tool.check-manifest]
+ignore = [
+  ".yaml",
+  "tox.ini",
+  "tests/",
+  "tests/test_unit/",
+  "tests/test_integration/",
+]
+
+[tool.ruff]
+line-length = 79
+exclude = ["__init__.py","build",".eggs"]
+select = ["I", "E", "F"]
+fix = true
+
+[tool.tox]
+legacy_tox_ini = """
+[tox]
+envlist = py{39,310,311}
+isolated_build = True
+
+[gh-actions]
+python =
+    3.9: py39
+    3.10: py310
+    3.11: py311
+
+[testenv]
+extras =
+    dev
+commands =
+    pytest -v --color=yes --cov=brainglobe_heatmap --cov-report=xml
+"""
diff --git a/setup.cfg b/setup.cfg
deleted file mode 100644
index 69c04f2..0000000
--- a/setup.cfg
+++ /dev/null
@@ -1,5 +0,0 @@
-[flake8]
-ignore = E203,W503,E501,E731,C901,W291,W293,E402,E722,E741,W605,E999,W293,W291,W293,E203
-max-line-length = 79
-max-complexity = 18
-exclude = __init__.py,tests/test_imports.py
\ No newline at end of file
diff --git a/setup.py b/setup.py
deleted file mode 100644
index fe3576a..0000000
--- a/setup.py
+++ /dev/null
@@ -1,24 +0,0 @@
-from setuptools import setup, find_namespace_packages
-from os import path
-
-this_directory = path.abspath(path.dirname(__file__))
-with open(path.join(this_directory, "README.md"), encoding="utf-8") as f:
-    long_description = f.read()
-
-requirements = ["brainrender", "matplotlib", "numpy", "myterial", "rich"]
-
-setup(
-    name="bgheatmap",
-    version="0.0.2",
-    description="Brain regions heatmaps in brainrender",
-    long_description=long_description,
-    long_description_content_type="text/markdown",
-    install_requires=requirements,
-    python_requires=">=3.6",
-    packages=find_namespace_packages(exclude=("control")),
-    include_package_data=True,
-    url="",
-    author="Federico Claudi",
-    zip_safe=False,
-    entry_points={"console_scripts": []},
-)


Test patch:
diff --git a/.github/workflows/test_and_deploy.yml b/.github/workflows/test_and_deploy.yml
new file mode 100644
index 0000000..241341b
--- /dev/null
+++ b/.github/workflows/test_and_deploy.yml
@@ -0,0 +1,66 @@
+name: tests
+
+on:
+  push:
+    branches:
+      - '*'
+    tags:
+      - '*'
+  pull_request:
+
+jobs:
+  linting:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: neuroinformatics-unit/actions/lint@v2
+
+  manifest:
+    name: Check Manifest
+    runs-on: ubuntu-latest
+    steps:
+      - uses: neuroinformatics-unit/actions/check_manifest@v2
+
+  test:
+    needs: [linting, manifest]
+    name: ${{ matrix.os }} py${{ matrix.python-version }}
+    runs-on: ${{ matrix.os }}
+    strategy:
+      matrix:
+        # Run all supported Python versions on linux
+        python-version: ["3.9", "3.10", "3.11"]
+        os: [ubuntu-latest]
+        # Include one windows and macos run
+        include:
+        - os: macos-latest
+          python-version: "3.10"
+        - os: windows-latest
+          python-version: "3.10"
+
+    steps:
+      # Run tests
+      - uses: neuroinformatics-unit/actions/test@v2
+        with:
+          python-version: ${{ matrix.python-version }}
+
+  build_sdist_wheels:
+    name: Build source distribution
+    needs: [test]
+    if: github.event_name == 'push' && github.ref_type == 'tag'
+    runs-on: ubuntu-latest
+    steps:
+    - uses: neuroinformatics-unit/actions/build_sdist_wheels@v2
+
+
+  upload_all:
+    name: Publish build distributions
+    needs: [build_sdist_wheels]
+    runs-on: ubuntu-latest
+    steps:
+    - uses: actions/download-artifact@v3
+      with:
+        name: artifact
+        path: dist
+    - uses: pypa/gh-action-pypi-publish@v1.5.0
+      with:
+        user: __token__
+        password: ${{ secrets.TWINE_API_KEY }}
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/test_integration/__init__.py b/tests/test_integration/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/test_unit/__init__.py b/tests/test_unit/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/test_unit/test_placeholder.py b/tests/test_unit/test_placeholder.py
new file mode 100644
index 0000000..3ada1ee
--- /dev/null
+++ b/tests/test_unit/test_placeholder.py
@@ -0,0 +1,2 @@
+def test_placeholder():
+    assert True