Repository: napari/napari
Pull request #7787: Add camera orientation parameters to application settings.
Author: login: TimMonko
Labels: name: tests
description: Something related to our tests
name: qt
description: Relates to qt
name: enhancement
name: highlight
description: PR that should be mentioned in next release notes
name: preferences
Created: 2025-04-06T03:43:55Z | Merged: 2025-04-08T13:57:14Z

# References and relevant issues

Closes #7784. Follow up to #7663.
Discussed previously in #7683.

# Description

Adds `camera.orientation` parameters to application settings. 

Maybe someone can think of more creative Field descriptions than I've used for the tooltip hovers. 


Linked issue #7784: Add camera orientation to preferences
## 🚀 Feature
<!-- A clear and concise description of the feature proposal -->

Add camera orientation parameters as a settable preference. Follow-up to #7663 and the last remaining part of #7683 

Working on this now.



Diff:
diff --git a/napari/_qt/widgets/_tests/test_qt_viewer_buttons.py b/napari/_qt/widgets/_tests/test_qt_viewer_buttons.py
index f58b3ca7a30..5e4fb2a32a5 100644
--- a/napari/_qt/widgets/_tests/test_qt_viewer_buttons.py
+++ b/napari/_qt/widgets/_tests/test_qt_viewer_buttons.py
@@ -7,12 +7,12 @@
 from napari._app_model._app import get_app_model
 from napari._qt.dialogs.qt_modal import QtPopup
 from napari._qt.widgets.qt_viewer_buttons import QtViewerButtons
-from napari.components.camera import (
+from napari.components.viewer_model import ViewerModel
+from napari.utils.camera_orientations import (
     DepthAxisOrientation,
     HorizontalAxisOrientation,
     VerticalAxisOrientation,
 )
-from napari.components.viewer_model import ViewerModel
 from napari.viewer import Viewer
 
 
diff --git a/napari/_qt/widgets/qt_viewer_buttons.py b/napari/_qt/widgets/qt_viewer_buttons.py
index 84e1f00b30b..2eae564798b 100644
--- a/napari/_qt/widgets/qt_viewer_buttons.py
+++ b/napari/_qt/widgets/qt_viewer_buttons.py
@@ -20,7 +20,8 @@
 from napari._qt.widgets.qt_dims_sorter import QtDimsSorter
 from napari._qt.widgets.qt_spinbox import QtSpinBox
 from napari._qt.widgets.qt_tooltip import QtToolTipLabel
-from napari.components.camera import (
+from napari.utils.action_manager import action_manager
+from napari.utils.camera_orientations import (
     DepthAxisOrientation,
     DepthAxisOrientationStr,
     HorizontalAxisOrientation,
@@ -28,7 +29,6 @@
     VerticalAxisOrientation,
     VerticalAxisOrientationStr,
 )
-from napari.utils.action_manager import action_manager
 from napari.utils.misc import in_ipython, in_jupyter, in_python_repl
 from napari.utils.translations import trans
 
diff --git a/napari/components/camera.py b/napari/components/camera.py
index 8ff1dcddeff..527ae2200c3 100644
--- a/napari/components/camera.py
+++ b/napari/components/camera.py
@@ -1,51 +1,26 @@
-from enum import auto
-from typing import TYPE_CHECKING, Literal, Optional
+from typing import TYPE_CHECKING, Optional
 
 import numpy as np
 from scipy.spatial.transform import Rotation as R
 
 from napari._pydantic_compat import validator
+from napari.utils.camera_orientations import (
+    DEFAULT_ORIENTATION_TYPED,
+    DepthAxisOrientation,
+    Handedness,
+    HorizontalAxisOrientation,
+    HorizontalAxisOrientationStr,
+    VerticalAxisOrientation,
+    VerticalAxisOrientationStr,
+)
 from napari.utils.events import EventedModel
-from napari.utils.misc import StringEnum, ensure_n_tuple
+from napari.utils.misc import ensure_n_tuple
 from napari.utils.translations import trans
 
 if TYPE_CHECKING:
     import numpy.typing as npt
 
 
-class VerticalAxisOrientation(StringEnum):
-    UP = auto()
-    DOWN = auto()
-
-
-class HorizontalAxisOrientation(StringEnum):
-    LEFT = auto()
-    RIGHT = auto()
-
-
-class DepthAxisOrientation(StringEnum):
-    AWAY = auto()
-    TOWARDS = auto()
-
-
-class Handedness(StringEnum):
-    RIGHT = auto()
-    LEFT = auto()
-
-
-VerticalAxisOrientationStr = Literal['up', 'down']
-HorizontalAxisOrientationStr = Literal['left', 'right']
-DepthAxisOrientationStr = Literal['away', 'torwards']
-
-
-DEFAULT_ORIENTATION_TYPED = (
-    DepthAxisOrientation.TOWARDS,
-    VerticalAxisOrientation.DOWN,
-    HorizontalAxisOrientation.RIGHT,
-)
-DEFAULT_ORIENTATION = tuple(map(str, DEFAULT_ORIENTATION_TYPED))
-
-
 class Camera(EventedModel):
     """Camera object modeling position and view of the camera.
 
diff --git a/napari/components/viewer_model.py b/napari/components/viewer_model.py
index 3c31b0e3b55..01a6c06a199 100644
--- a/napari/components/viewer_model.py
+++ b/napari/components/viewer_model.py
@@ -234,6 +234,17 @@ def __init__(
             self._tooltip_visible_update
         )
 
+        self._update_camera_orientation()
+        settings.application.events.depth_axis_orientation.connect(
+            self._update_camera_orientation
+        )
+        settings.application.events.vertical_axis_orientation.connect(
+            self._update_camera_orientation
+        )
+        settings.application.events.horizontal_axis_orientation.connect(
+            self._update_camera_orientation
+        )
+
         self._update_viewer_grid()
         settings.application.events.grid_stride.connect(
             self._update_viewer_grid
@@ -312,6 +323,16 @@ def _brush_circle_overlay(self):
     def _tooltip_visible_update(self, event):
         self.tooltip.visible = event.value
 
+    def _update_camera_orientation(self):
+        """Update camera orientation based on settings."""
+        settings = get_settings()
+
+        self.camera.orientation = (
+            settings.application.depth_axis_orientation,
+            settings.application.vertical_axis_orientation,
+            settings.application.horizontal_axis_orientation,
+        )
+
     def _update_viewer_grid(self):
         """Keep viewer grid settings up to date with settings values."""
 
diff --git a/napari/settings/_application.py b/napari/settings/_application.py
index af7bd730e36..9faa3f2352c 100644
--- a/napari/settings/_application.py
+++ b/napari/settings/_application.py
@@ -10,6 +10,12 @@
 )
 from napari.settings._fields import Language
 from napari.utils._base import _DEFAULT_LOCALE
+from napari.utils.camera_orientations import (
+    DEFAULT_ORIENTATION_TYPED,
+    DepthAxisOrientation,
+    HorizontalAxisOrientation,
+    VerticalAxisOrientation,
+)
 from napari.utils.events.custom_types import confloat, conint
 from napari.utils.events.evented_model import EventedModel
 from napari.utils.notifications import NotificationSeverity
@@ -155,6 +161,26 @@ class ApplicationSettings(EventedModel):
         description=trans._('Loop mode for playback.'),
     )
 
+    depth_axis_orientation: DepthAxisOrientation = Field(
+        default=DEFAULT_ORIENTATION_TYPED[0],
+        title=trans._('Depth Axis Orientation'),
+        description=trans._('Orientation of the depth axis in 3D view.'),
+    )
+    vertical_axis_orientation: VerticalAxisOrientation = Field(
+        default=DEFAULT_ORIENTATION_TYPED[1],
+        title=trans._('Vertical Axis Orientation'),
+        description=trans._(
+            'Orientation of the vertical axis in 2D and 3D view.'
+        ),
+    )
+    horizontal_axis_orientation: HorizontalAxisOrientation = Field(
+        default=DEFAULT_ORIENTATION_TYPED[2],
+        title=trans._('Horizontal Axis Orientation'),
+        description=trans._(
+            'Orientation of the horizontal axis in 2D and 3D view.'
+        ),
+    )
+
     grid_stride: GridStride = Field(  # type: ignore [valid-type]
         default=1,
         title=trans._('Grid Stride'),
diff --git a/napari/utils/camera_orientations.py b/napari/utils/camera_orientations.py
new file mode 100644
index 00000000000..45070b6813b
--- /dev/null
+++ b/napari/utils/camera_orientations.py
@@ -0,0 +1,37 @@
+from enum import auto
+from typing import Literal
+
+from napari.utils.misc import StringEnum
+
+
+class VerticalAxisOrientation(StringEnum):
+    UP = auto()
+    DOWN = auto()
+
+
+class HorizontalAxisOrientation(StringEnum):
+    LEFT = auto()
+    RIGHT = auto()
+
+
+class DepthAxisOrientation(StringEnum):
+    AWAY = auto()
+    TOWARDS = auto()
+
+
+class Handedness(StringEnum):
+    RIGHT = auto()
+    LEFT = auto()
+
+
+VerticalAxisOrientationStr = Literal['up', 'down']
+HorizontalAxisOrientationStr = Literal['left', 'right']
+DepthAxisOrientationStr = Literal['away', 'towards']
+
+
+DEFAULT_ORIENTATION_TYPED = (
+    DepthAxisOrientation.TOWARDS,
+    VerticalAxisOrientation.DOWN,
+    HorizontalAxisOrientation.RIGHT,
+)
+DEFAULT_ORIENTATION = tuple(map(str, DEFAULT_ORIENTATION_TYPED))


Reviews:
user: login: jni
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-06T04:31:02Z
user: login: jni
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-06T04:34:40Z
user: login: jni
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-07T01:04:30Z
user: login: psobolewskiPhD
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-07T01:12:40Z
user: login: TimMonko
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T01:26:50Z
user: login: jni
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-07T01:29:02Z
user: login: jni
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-08T07:28:48Z

Review comments:
diff_hunk: @@ -35,7 +35,7 @@ class Handedness(StringEnum):
 
 VerticalAxisOrientationStr = Literal['up', 'down']
 HorizontalAxisOrientationStr = Literal['left', 'right']
-DepthAxisOrientationStr = Literal['away', 'torwards']
+DepthAxisOrientationStr = Literal['away', 'towards']
path: napari/components/camera.py
user: login: jni
body: 😅 🙏 
created_at: 2025-04-06T04:31:02Z
author_association: MEMBER
diff_hunk: @@ -234,6 +234,17 @@ def __init__(
             self._tooltip_visible_update
         )
 
+        self._update_camera_orientation()
+        settings.application.events.depth_axis_orientation.connect(
+            self._update_camera_orientation
+        )
+        settings.application.events.vertical_axis_orientation.connect(
+            self._update_camera_orientation
+        )
+        settings.application.events.horizontal_axis_orientation.connect(
+            self._update_camera_orientation
+        )
+
path: napari/components/viewer_model.py
user: login: jni
body: I'm not 100% I want this connection — do we normally do this? Like, in my brain, startup settings and current session settings are separate things.

To be clear, I can be convinced either way.
created_at: 2025-04-06T04:34:40Z
author_association: MEMBER
diff_hunk: @@ -234,6 +234,17 @@ def __init__(
             self._tooltip_visible_update
         )
 
+        self._update_camera_orientation()
+        settings.application.events.depth_axis_orientation.connect(
+            self._update_camera_orientation
+        )
+        settings.application.events.vertical_axis_orientation.connect(
+            self._update_camera_orientation
+        )
+        settings.application.events.horizontal_axis_orientation.connect(
+            self._update_camera_orientation
+        )
+
path: napari/components/viewer_model.py
user: login: jni
body: @psobolewskiPhD do you have any thoughts on this? 👆 ie, if we update the settings, do we update the current session correspondingly?
created_at: 2025-04-07T01:04:30Z
author_association: MEMBER
diff_hunk: @@ -234,6 +234,17 @@ def __init__(
             self._tooltip_visible_update
         )
 
+        self._update_camera_orientation()
+        settings.application.events.depth_axis_orientation.connect(
+            self._update_camera_orientation
+        )
+        settings.application.events.vertical_axis_orientation.connect(
+            self._update_camera_orientation
+        )
+        settings.application.events.horizontal_axis_orientation.connect(
+            self._update_camera_orientation
+        )
+
path: napari/components/viewer_model.py
user: login: psobolewskiPhD
body: I think so?
That's been a hack to provide gui things, put it in the settings!
That's how theme, font, etc. all work.

I think most apps work that way? if you change the settings it applies immediately, no restart required.
created_at: 2025-04-07T01:12:40Z
author_association: MEMBER
diff_hunk: @@ -234,6 +234,17 @@ def __init__(
             self._tooltip_visible_update
         )
 
+        self._update_camera_orientation()
+        settings.application.events.depth_axis_orientation.connect(
+            self._update_camera_orientation
+        )
+        settings.application.events.vertical_axis_orientation.connect(
+            self._update_camera_orientation
+        )
+        settings.application.events.horizontal_axis_orientation.connect(
+            self._update_camera_orientation
+        )
+
path: napari/components/viewer_model.py
user: login: TimMonko
body: I'm 99% sure this is the way we want things. It would be so unintuitive to change a setting and not have it apply in the current session. 

Also, this is how all the other settings currently work (grid, theme, label data type, shortcuts, etc etc.) 

If it didn't work to change the current session it would definitely be inconsistent with the rest of the settings, and other programs in general. 
created_at: 2025-04-07T01:26:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -234,6 +234,17 @@ def __init__(
             self._tooltip_visible_update
         )
 
+        self._update_camera_orientation()
+        settings.application.events.depth_axis_orientation.connect(
+            self._update_camera_orientation
+        )
+        settings.application.events.vertical_axis_orientation.connect(
+            self._update_camera_orientation
+        )
+        settings.application.events.horizontal_axis_orientation.connect(
+            self._update_camera_orientation
+        )
+
path: napari/components/viewer_model.py
user: login: jni
body: Great, thanks both for your input, I'll go back to my corner now! 😂 
created_at: 2025-04-07T01:29:02Z
author_association: MEMBER

PR comments:
user: login: TimMonko
created_at: 2025-04-06T04:03:05Z
body: Are test fails unrelated? I don't see other PRs with this issue. CircleCI fail is also unrelated?
author_association: CONTRIBUTOR
user: login: jni
created_at: 2025-04-06T05:16:55Z
body: I have no idea what is going on with CI 😭 
author_association: MEMBER
user: login: jni
created_at: 2025-04-06T05:22:47Z
body: Last commits to main were green:

![CleanShot 2025-04-06 at 15 22 13@2x](https://github.com/user-attachments/assets/210f336e-59fb-4447-9a57-0f77b5029cb1)

author_association: MEMBER
user: login: psobolewskiPhD
created_at: 2025-04-06T05:24:18Z
body: The circleCI docs fail seems like a fail related to magicgui page?
/home/circleci/project/docs/docs/howtos/extending/magicgui.md: WARNING: Executing notebook failed: CellExecutionError [mystnb.exec]
/home/circleci/project/docs/docs/howtos/extending/magicgui.md: WARNING: Notebook exception traceback saved in: /home/circleci/project/docs/docs/_build/html/reports/howtos/extending/magicgui.err.log [mystnb.exec]

not sure why this PR is causing issues there. I re-ran it 🤷 
author_association: MEMBER
user: login: jni
created_at: 2025-04-06T06:02:48Z
body: Other PRs seem to have the same failures. Not just in Circle. 
author_association: MEMBER
user: login: psobolewskiPhD
created_at: 2025-04-06T15:44:21Z
body: > Other PRs seem to have the same failures. Not just in Circle.

Not seeing it, e.g. this one that doesn't touch code:
https://github.com/napari/napari/pull/7789
author_association: MEMBER
user: login: jni
created_at: 2025-04-07T01:30:09Z
body: Maybe we also need to bump the patch number of the settings schema version?
author_association: MEMBER
user: login: TimMonko
created_at: 2025-04-07T01:41:39Z
body: > Maybe we also need to bump the patch number of the settings schema version?

I can do this, but the comments don't say what to do when adding a new option to the settings. I can also update the comment if you think it needs a bump. Besides, we didn't have to bump the patch in https://github.com/napari/napari/pull/7597/files
author_association: CONTRIBUTOR
user: login: TimMonko
created_at: 2025-04-07T01:46:47Z
body: What in the world, the failing test `test_plugin_discovery_is_delayed` does indeed fail locally on my branch, but passes locally on main. 

Why would this PR be touching that in any way?


author_association: CONTRIBUTOR
user: login: TimMonko
created_at: 2025-04-07T01:55:33Z
body: Ahhh, so it is a real failure. By importing napari.components.camera into _application.py I'm creating a circular import because the test is ultimately triggering an import of napari.settings, triggering importing napari.components.camera, triggering napari.plugins again. 

Not sure the right way to fix this.... 
author_association: CONTRIBUTOR
user: login: TimMonko
created_at: 2025-04-07T03:32:04Z
body: Ok, someone tell me if it's the right fix or not, but I separated out the orientation enums to a new module to prevent the circular import. I put it in utils because, uh, the architecture guide says 'commonly used classes and functions imported in variety of places'
author_association: CONTRIBUTOR
user: login: TimMonko
created_at: 2025-04-07T04:03:24Z
body: Definitely unsure of what to do now since 3.10 passed and 3.13 didn't (but kind of the same test failing 3 times)
author_association: CONTRIBUTOR
user: login: jni
created_at: 2025-04-07T07:44:55Z
body: I think the key is not py310 vs py313, but pyqt5 vs pyqt6. 😕 

Either way, looking at it, I definitely don't understand why your changes would affect widget focus (all failures are related to this). The preferences issue maybe makes a bit of sense because it appears to use clicking in particular regions to gain focus — but the shape of the preferences dialog is affected by this PR. But the console issue? 🤔 😵‍💫 
author_association: MEMBER
user: login: TimMonko
created_at: 2025-04-07T15:10:06Z
body: So, looking at the tests closely now. First, on pyqt6 they pass locally for me. (except param 1 fails on `assert shortcut == 'U'` because its actually Z, not sure about that, but the behavior is the same on main). 

But again I agree, that nothing I've done should at all change how Preferences -> shortcuts or the console focus works. Maybe we can re-run CI and see what happens? 
author_association: CONTRIBUTOR
user: login: TimMonko
created_at: 2025-04-07T15:30:28Z
body: Thank you for re-running CI, Peter. Makes me very happy to see it chug along through the initial tests. 

Do we have a list of flaky tests anywhere? If not, I can start an issue and perhaps we can try to keep track in case there is a pattern we can identify. 
author_association: CONTRIBUTOR
user: login: codecov[bot]
created_at: 2025-04-07T15:57:46Z
body: ## [Codecov](https://app.codecov.io/gh/napari/napari/pull/7787?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=napari) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 92.95%. Comparing base [(`8f6a23f`)](https://app.codecov.io/gh/napari/napari/commit/8f6a23f9915fbb5dc561b5a3e02f2f27ee641998?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=napari) to head [(`714d08a`)](https://app.codecov.io/gh/napari/napari/commit/714d08ae94ebd37fd5ddc93ec5b3b9b8a744b844?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=napari).
> Report is 7 commits behind head on main.

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


```diff
@@            Coverage Diff             @@
##             main    #7787      +/-   ##
==========================================
- Coverage   92.98%   92.95%   -0.03%     
==========================================
  Files         633      634       +1     
  Lines       59444    59458      +14     
==========================================
- Hits        55273    55269       -4     
- Misses       4171     4189      +18     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/napari/napari/pull/7787?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=napari).   
: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=napari).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
author_association: NONE
user: login: jni
created_at: 2025-04-10T11:08:21Z
body: Just pasting a screenshot of the changes here so I can link to it from the release notes 😂 

![CleanShot 2025-04-10 at 21 00 04@2x](https://github.com/user-attachments/assets/f5032320-8b03-4ff7-9cb7-8b182ab232af)

author_association: MEMBER
user: login: psobolewskiPhD
created_at: 2025-04-10T14:58:12Z
body: Sorry I overlooked this PR. That screenshot does make me realize how complex this left/right hand things has gotten. With the 3 new options, I have no idea how to just change from left to right handed or back. E.g. if I wanted the old behavior vs. the new behavior. Obviously this needs to be adressed in the docs, but would be nice to have it somehow clearer in napari. Not sure how to communicate that here though, maybe the tooltips could say what the setting was <0.6
author_association: MEMBER
user: login: TimMonko
created_at: 2025-04-10T16:10:21Z
body: @psobolewskiPhD does this help resolve some of these concerns? https://github.com/napari/napari/pull/7770
Agreed we can highlight how the default changed better  -- depth (Z) is now towards instead of away, is the change in 0.6.0

Happy to have more feedback
author_association: CONTRIBUTOR
user: login: TimMonko
created_at: 2025-04-10T16:15:02Z
body: > maybe the tooltips could say what the setting was <0.6

Maybe that's a good idea. Curious @jni thoughts
author_association: CONTRIBUTOR
user: login: psobolewskiPhD
created_at: 2025-04-10T16:58:19Z
body: @TimMonko I was thinking in the context of the release notes and this PR specifically.
#7770 sort of helps? I mean it's still complex, but at least you see the little icon which helps. What still isn't 100% clear to me, e.g. a user trying to sort out what was before, is what was the historical napari setting/behavior that they were using/depending on. At least I can go on the left/right handedness icon, assuming I know that pre 0.6.0 it was *left handed*. Also that's just for the current viewer, so it won't help the person trying to change their settings. They would need to use it, experiment to figure out what combination gives what they used to see, then make a note, then edit the settings in the Preferences ...

In the Preferences, it's even harder for the user to know how to go back to what it used to be. At the very least the release notes should be explicit about what the settings to set for old behavior vs. the default set for new behavior.

If the only difference is the Depth axis setting, then I would argue that the tooltip there could provide the info e.g.:
"Orientation of the depth axis in 3D view.\nPrior to v0.6.0, default was FOO"
We can then eventually remove that if we want, but it seems pretty harmless and could be useful for someone doing some comparisons in the future.

Just to summarize, look at our messaging (https://forum.image.sc/t/upcoming-napari-0-6-0-release/108954):

> The default handedness of the 3D viewer is changing from left-handed to right-handed. Most users probably won’t be aware of this, but to a small percentage of users it will fix a bug that has existed since napari started supporting 3D volumes: they were mirror images of each other. ...

This sounds like a binary toggle. I have to say I understood it at his level, with the original PR that changed the behavior.  I think the 3 axes provide a lot of customization, but also introduce a lot of cognitive load for someone trying to sort out how to go back to how it was. I for one still find it confusing!

Edit: this should all be documented too of course, as per https://github.com/napari/docs/issues/570
author_association: MEMBER
user: login: imagesc-bot
created_at: 2025-05-07T04:23:29Z
body: This pull request has been mentioned on **Image.sc Forum**. There might be relevant details there:

https://forum.image.sc/t/napari-0-6-0-released/112147/1

author_association: NONE