Repository: brainglobe/brainrender

Issue:
[Feature] Flipping single neurons

Hi, is there an easy way to flip the neuron actor (neuron morphology loaded from an SWC file) so the soma is in the opposite hemisphere.

A way to flip any actor to the opposite hemisphere would be very much appreciated!

Alternatively, I would need to load the swc and slip it and I cannot find an easy way with morphapi to flip the morphology either.

Thanks!

Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/brainglobe/brainrender/pull/392?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=brainglobe) Report
Attention: Patch coverage is `50.00000%` with `5 lines` in your changes missing coverage. Please review.
> Project coverage is 86.16%. Comparing base [(`02ea8d7`)](https://app.codecov.io/gh/brainglobe/brainrender/commit/02ea8d7b6ae06715beebdbfbda560594d1416aa6?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=brainglobe) to head [(`53c20d9`)](https://app.codecov.io/gh/brainglobe/brainrender/commit/53c20d94275ed190d65e325143069819c5950656?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=brainglobe).

| [Files with missing lines](https://app.codecov.io/gh/brainglobe/brainrender/pull/392?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=brainglobe) | Patch % | Lines |
|---|---|---|
| [brainrender/actor.py](https://app.codecov.io/gh/brainglobe/brainrender/pull/392?src=pr&el=tree&filepath=brainrender%2Factor.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=brainglobe#diff-YnJhaW5yZW5kZXIvYWN0b3IucHk=) | 50.00% | [5 Missing :warning: ](https://app.codecov.io/gh/brainglobe/brainrender/pull/392?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=brainglobe) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main     #392      +/-   ##
==========================================
- Coverage   86.71%   86.16%   -0.55%     
==========================================
  Files          27       27              
  Lines        1219     1229      +10     
==========================================
+ Hits         1057     1059       +2     
- Misses        162      170       +8     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/brainglobe/brainrender/pull/392?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=brainglobe).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=brainglobe).


Patch:
diff --git a/brainrender/actor.py b/brainrender/actor.py
index 213525e..2e93e35 100644
--- a/brainrender/actor.py
+++ b/brainrender/actor.py
@@ -1,7 +1,11 @@
 from io import StringIO
+from typing import Optional
 
 import numpy as np
+import numpy.typing as npt
 import pyinspect as pi
+from brainglobe_atlasapi import BrainGlobeAtlas
+from brainglobe_space import AnatomicalSpace
 from myterial import amber, orange, salmon
 from rich.console import Console
 from vedo import Sphere, Text3D
@@ -201,6 +205,31 @@ class Actor:
 
         return sil
 
+    def mirror(
+        self,
+        axis: str,
+        origin: Optional[npt.NDArray] = None,
+        atlas: Optional[BrainGlobeAtlas] = None,
+    ):
+        """
+        Mirror the actor's mesh around the specified axis, using the
+        parent_center as the center of the mirroring operation. The axes can
+        be specified using an abbreviation, e.g. 'x' for the x-axis, or anatomical
+        convention e.g. 'sagittal'. If an atlas is provided, then the anatomical
+        space of the atlas is used, otherwise `asr` is assumed.
+
+        :param axis: str, axis around which to mirror the mesh
+        :param origin: np.ndarray, center of the mirroring operation
+        :param atlas: BrainGlobeAtlas, atlas object to use for anatomical space
+        """
+        if axis in ["sagittal", "vertical", "frontal"]:
+            anatomical_space = atlas.space if atlas else AnatomicalSpace("asr")
+
+            axis_ind = anatomical_space.get_axis_idx(axis)
+            axis = "x" if axis_ind == 0 else "y" if axis_ind == 1 else "z"
+
+        self.mesh = self.mesh.mirror(axis, origin)
+
     def __rich_console__(self, *args):
         """
         Print some useful characteristics to console.
diff --git a/brainrender/actors/neurons.py b/brainrender/actors/neurons.py
index 6e3aa5b..8722686 100644
--- a/brainrender/actors/neurons.py
+++ b/brainrender/actors/neurons.py
@@ -41,6 +41,7 @@ class Neuron(Actor):
         alpha=1,
         neurite_radius=8,
         soma_radius=15,
+        invert_dims=True,
         name=None,
     ):
         """
@@ -50,6 +51,8 @@ class Neuron(Actor):
         :param color: str,
         :param neuron_radius: float, radius of axon/dendrites
         :param soma_radius: float, radius of soma
+        :param invert_dims: bool, exchange the first and last dimension coordinates
+        when loading from a .swc file. e.g going from (x, y, z) to (z, y, x).
         :param name: str, actor name
         """
         logger.debug("Creating a Neuron actor")
@@ -61,7 +64,7 @@ class Neuron(Actor):
         self.name = None
 
         if isinstance(neuron, (str, Path)):
-            mesh = self._from_file(neuron)
+            mesh = self._from_file(neuron, invert_dims)
         elif isinstance(neuron, (Mesh)):
             mesh = neuron
         elif isinstance(neuron, Actor):
@@ -86,7 +89,7 @@ class Neuron(Actor):
         )[1]
         return mesh
 
-    def _from_file(self, neuron: (str, Path)):
+    def _from_file(self, neuron: (str, Path), invert_dims):
         path = Path(neuron)
         if not path.exists():
             raise FileExistsError(f"Neuron file doesn't exist: {path}")
@@ -99,5 +102,5 @@ class Neuron(Actor):
         self.name = self.name or path.name
 
         return self._from_morphapi_neuron(
-            MorphoNeuron(data_file=neuron, invert_dims=True)
+            MorphoNeuron(data_file=neuron, invert_dims=invert_dims)
         )
diff --git a/brainrender/atlas_specific/allen_brain_atlas/gene_expression/api.py b/brainrender/atlas_specific/allen_brain_atlas/gene_expression/api.py
index 57340da..a07481f 100644
--- a/brainrender/atlas_specific/allen_brain_atlas/gene_expression/api.py
+++ b/brainrender/atlas_specific/allen_brain_atlas/gene_expression/api.py
@@ -1,7 +1,9 @@
 import os
 import sys
+from time import sleep
 
 import pandas as pd
+import requests
 from loguru import logger
 
 from brainrender import base_dir
@@ -86,7 +88,18 @@ class GeneExpressionAPI:
         :param gene_symbol: str
         """
         url = self.gene_experiments_url.replace("-GENE_SYMBOL-", gene)
-        data = request(url).json()["msg"]
+        max_retries = 8
+        delay = 4
+        data = None
+
+        for i in range(max_retries):
+            try:
+                data = request(url).json()["msg"]
+                break
+            except requests.exceptions.JSONDecodeError:
+                print(f"Unable to connect to Allen API, retrying in {delay}")
+                sleep(delay)
+                delay *= 2
 
         if not len(data):
             print(f"No experiment found for gene {gene}")
diff --git a/examples/mirror_actors.py b/examples/mirror_actors.py
new file mode 100644
index 0000000..352de5c
--- /dev/null
+++ b/examples/mirror_actors.py
@@ -0,0 +1,68 @@
+from pathlib import Path
+
+import numpy as np
+from myterial import orange
+from rich import print
+
+from brainrender import Scene
+from brainrender._io import load_mesh_from_file
+from brainrender.actor import Actor
+from brainrender.actors import Neuron, Points
+
+neuron_file = Path(__file__).parent.parent / "resources" / "neuron1.swc"
+obj_file = Path(__file__).parent.parent / "resources" / "CC_134_1_ch1inj.obj"
+probe_striatum = (
+    Path(__file__).parent.parent / "resources" / "probe_1_striatum.npy"
+)
+
+print(f"[{orange}]Running example: {Path(__file__).name}")
+
+# Create a brainrender scene
+scene = Scene(title="mirrored actors")
+
+# Add the neuron
+neuron_original = Neuron(neuron_file)
+scene.add(neuron_original)
+
+# Add a mesh from a file
+scene.add(obj_file, color="tomato")
+
+# Add a probe from a file
+scene.add(
+    Points(
+        np.load(probe_striatum),
+        name="probe_1",
+        colors="darkred",
+        radius=50,
+    ),
+    color="darkred",
+    radius=50,
+)
+
+# Add mirrored objects
+axis = "frontal"
+atlas_center = scene.root.center
+
+neuron_mirrored = Neuron(neuron_file)
+neuron_mirrored.mirror(axis, origin=atlas_center, atlas=scene.atlas)
+scene.add(neuron_mirrored)
+
+mesh_mirrored = Actor(
+    load_mesh_from_file(obj_file, color="tomato"),
+    name=obj_file.name,
+    br_class="from file",
+)
+mesh_mirrored.mirror(axis, origin=atlas_center, atlas=scene.atlas)
+scene.add(mesh_mirrored)
+
+mirrored_probe = Points(
+    np.load(probe_striatum),
+    name="probe_1",
+    colors="darkred",
+    radius=50,
+)
+mirrored_probe.mirror(axis, origin=atlas_center, atlas=scene.atlas)
+scene.add(mirrored_probe)
+
+# Render!
+scene.render()
diff --git a/examples/neurons.py b/examples/neurons.py
index 5888116..250b736 100644
--- a/examples/neurons.py
+++ b/examples/neurons.py
@@ -4,6 +4,7 @@ import requests.exceptions
 from morphapi.api.mouselight import MouseLightAPI
 from myterial import orange
 from rich import print
+from urllib3.exceptions import NewConnectionError, MaxRetryError
 
 from brainrender import Scene
 from brainrender.actors import Neuron, make_neurons
@@ -28,7 +29,12 @@ try:
     to_add = [neurons_metadata[47], neurons_metadata[51]]
     neurons = mlapi.download_neurons(to_add)
     neurons = scene.add(*make_neurons(*neurons, neurite_radius=12))
-except ConnectionError or requests.exceptions.ReadTimeout as e:
+except (
+    NewConnectionError,
+    MaxRetryError,
+    requests.exceptions.ConnectionError,
+    requests.exceptions.ReadTimeout,
+) as e:
     print("Failed to download neurons data from neuromorpho.org.")
 
 # Render!


Test patch:
diff --git a/tests/test_actor.py b/tests/test_actor.py
index c792c4d..3f70341 100644
--- a/tests/test_actor.py
+++ b/tests/test_actor.py
@@ -1,10 +1,24 @@
+from pathlib import Path
+
+import pytest
+from brainglobe_space import AnatomicalSpace
 from rich import print as rprint
 from vedo import Mesh
 
 from brainrender import Scene
+from brainrender._io import load_mesh_from_file
 from brainrender.actor import Actor
 
 
+@pytest.fixture
+def mesh_actor():
+    resources_dir = Path(__file__).parent.parent / "resources"
+    data_path = resources_dir / "CC_134_1_ch1inj.obj"
+    obj_mesh = load_mesh_from_file(data_path, color="tomato")
+
+    return Actor(obj_mesh, name=data_path.name, br_class="from file")
+
+
 def test_actor():
     s = Scene()
 
@@ -18,3 +32,105 @@ def test_actor():
     assert s.alpha() == s.mesh.alpha()
     assert s.name == "root"
     assert s.br_class == "brain region"
+
+
+@pytest.mark.parametrize(
+    "axis, expected_ind",
+    [
+        ("z", 2),
+        ("y", 1),
+        ("x", 0),
+        ("frontal", 2),
+        ("vertical", 1),
+        ("sagittal", 0),
+    ],
+)
+def test_mirror_origin(mesh_actor, axis, expected_ind):
+    original_center = mesh_actor.center
+    mesh_actor.mirror(axis)
+    new_center = mesh_actor.center
+
+    assert new_center[expected_ind] == -original_center[expected_ind]
+
+
+@pytest.mark.parametrize(
+    "axis, expected_ind",
+    [
+        ("z", 2),
+        ("y", 1),
+        ("x", 0),
+        ("frontal", 2),
+        ("vertical", 1),
+        ("sagittal", 0),
+    ],
+)
+def test_mirror_around_root(mesh_actor, axis, expected_ind):
+    scene = Scene()
+    root_center = scene.root.center
+
+    original_center = mesh_actor.center
+    mesh_actor.mirror(axis, origin=root_center)
+    new_center = mesh_actor.center
+
+    # The new center should be the same distance from the root center as the original center
+    expected_location = (
+        -(original_center[expected_ind] - root_center[expected_ind])
+        + root_center[expected_ind]
+    )
+
+    assert new_center[expected_ind] == pytest.approx(
+        expected_location, abs=1e-3
+    )
+
+
+@pytest.mark.parametrize(
+    "axis, expected_ind",
+    [
+        ("z", 2),
+        ("y", 1),
+        ("x", 0),
+        ("frontal", 1),
+        ("vertical", 0),
+        ("sagittal", 2),
+    ],
+)
+def test_mirror_custom_space(mesh_actor, axis, expected_ind):
+    scene = Scene()
+    scene.atlas.space = AnatomicalSpace("sra")
+
+    original_center = mesh_actor.center
+    mesh_actor.mirror(axis, atlas=scene.atlas)
+    new_center = mesh_actor.center
+
+    assert new_center[expected_ind] == -original_center[expected_ind]
+
+
+@pytest.mark.parametrize(
+    "axis, expected_ind",
+    [
+        ("z", 2),
+        ("y", 1),
+        ("x", 0),
+        ("frontal", 1),
+        ("vertical", 0),
+        ("sagittal", 2),
+    ],
+)
+def test_mirror_custom_space_around_root(mesh_actor, axis, expected_ind):
+    scene = Scene()
+    scene.atlas.space = AnatomicalSpace("sra")
+    root_center = scene.root.center
+
+    original_center = mesh_actor.center
+    mesh_actor.mirror(axis, origin=root_center, atlas=scene.atlas)
+    new_center = mesh_actor.center
+
+    # The new center should be the same distance from the root center as the original center
+    expected_location = (
+        -(original_center[expected_ind] - root_center[expected_ind])
+        + root_center[expected_ind]
+    )
+
+    assert new_center[expected_ind] == pytest.approx(
+        expected_location, abs=1e-3
+    )