8000 Expose additional Camera parameters in GUI with 3D popup widget by TimMonko · Pull Request #7626 · napari/napari · GitHub
[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
Skip to content

Expose additional Camera parameters in GUI with 3D popup widget #7626

8000
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 15 commits into from
Feb 26, 2025

Conversation

TimMonko
Copy link
Contributor
@TimMonko TimMonko commented Feb 19, 2025

References and relevant issues

Task in #7587 to expose more canvas settings in the GUI. This exposes zoom and angles of the Camera in the popup widget

Description

A user wanted to reproduce the zoom and angle of the 3D camera between sessions, but with the desire to not use the console. I'd think this is not uncommon. This PR adds multiple widgets to the ndisplay==3 popup to expose these settings, and adds zoom to ndisplay==2. It adds a labeled slider widget to all camera settings. Adding this in the form of a widget also increases accessibility for users with difficulty operating a pointer for spinning. Open to discussion about step size and number of decimals displayed. Because this widget popup cannot be used at the same time as the canvas itself, I don't think too many callbacks need hooked up.

Current behavior is to bound angles with X[-180, 180], Y(-90,90) and Z[-180,180] because values outside (-90,90) for Y result in recalculation of viewer.camera.angles. See this comment chain for discussion of this implementation.

Current PR:
image

Original Description

This PR adds multiple widgets to the ndisplay==3 popup to expose these settings. It also changes the perspective slider to a spinbox; however, if this is well received it would be nice to update each of these widgets to include a slider (for easy spinning) and the spinbox. Adding this in the form of a widget also increases accessibility for users with difficulty operating a pointer for spinning. Open to discussion about step size and number of decimals displayed. Because this widget popup cannot be used at the same time as the canvas itself, I don't think too many callbacks need hooked up.

image

@github-actions github-actions bot added the qt Relates to qt label Feb 19, 2025
@TimMonko
Copy link
Contributor Author

Also, I thought it would be nice to use a magicgui widget to simplify the angle widgets and callbacks, but it results in this really odd appearance. Any reason to avoid or not avoid using magicgui for these things? I know @magicgui is used in other parts of the codebase
image

        from magicgui.widgets import TupleEdit
        angles = TupleEdit(
            value=self.viewer.camera.angles,
            layout='vertical',
            labels=['rx', 'ry', 'rz'],
            widget_type='FloatSpinBox',
            options={'step': 5, 'min': -180, 'max': 180},
        )

        # make layout
        form_layout = QFormLayout()
        form_layout.insertRow(0, QLabel(trans._('Perspective:')), perspective)
        form_layout.insertRow(1, QLabel(trans._('Zoom:')), zoom)
        form_layout.insertRow(2, angles.native)

@hanjinliu
Copy link
Contributor

Very nice feature!
When you tried magicgui, did you not forget to comment out the QDoubleSpinBox for rz? Because the widget for 90.0 is duplicated I think there is a widget that has the popup as its parent but is not added to the popup layout.

Another thing I'd like to mention is that the labels parameter of TupleEdit is not expected to be used as a list of labels for each item; it was simply inherited from Container, meaning whether the parameter labels should be shown. I think there's no benefit of using magicgui here unless all the contents are built with @magicgui decorator.

@github-actions github-actions bot added the tests Something related to our tests label Feb 20, 2025
@TimMonko
Copy link
Contributor Author

Very nice feature!

Thanks, I am worried it's exposed a few too many options -- and may just be confusing for people to have all the camera options, but maybe not. It's not like the perspective slider was particularly intuitive previously though.

When you tried magicgui, did you not forget to comment out the QDoubleSpinBox for rz? Because the widget for 90.0 is duplicated I think there is a widget that has the popup as its parent but is not added to the popup layout.

Yep, that was the problem! Nice catch, something I had missed.

Another thing I'd like to mention is that the labels parameter of TupleEdit is not expected to be used as a list of labels for each item; it was simply inherited from Container, meaning whether the parameter labels should be shown. I think there's no benefit of using magicgui here unless all the contents are built with @magicgui decorator.

Yes, I realized the labels bit after I walked away. One reason I like using magicgui widgets directly is that it gets rid of a lot of the 'boilerplate' that I don't like about Qt, but that's really just personal preference.

This does sort of do what I want, but the spinboxes having it all horizontal is too wide for sure.

image

Copy link
codecov bot commented Feb 20, 2025

Codecov Report

All modified and coverable lines are covered by tests ✅

Project coverage is 92.85%. Comparing base (b603681) to head (4db80e7).
Report is 5 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #7626      +/-   ##
==========================================
- Coverage   92.88%   92.85%   -0.03%     
==========================================
  Files         630      630              
  Lines       58994    59066      +72     
==========================================
+ Hits        54794    54845      +51     
- Misses       4200     4221      +21     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

@psobolewskiPhD
Copy link
Member

Are decimals for the angles needed? is that sort of precision useful?

@TimMonko
Copy link
Contributor Author

Are decimals for the angles needed? is that sort of precision useful?

I think they are not needed, especially when using it to set it. My thought was, I suppose, that if you rotated an object into exactly the spot you want it, then the additional precision could be useful? I'd think rounding to the closest whole number should be fine still in that case. Good feedback!

@TimMonko
Copy link
Contributor Author < 8000 /span>

currently:
image

@hanjinliu
Copy link
Contributor

When I heard a "camera control widget" I was thinking of using sliders rather than spin boxes. The slider widgets of magicgui and superqt supports direct user input, and they have smoother interactivity. I wonder which other people prefer.

@TimMonko TimMonko requested a review from a team as a code owner February 21, 2025 21:16
@TimMonko
Copy link
Contributor Author

Updated original PR description. Made lots of changes to layout, callbacks, and now use superqt's labeled sliders. It will also now not open outside the napari window. Thanks for pointing me in this direction @hanjinliu

415826607-eb553e41-a3d0-40d9-a303-7896e2a1c7c6

@@ -204,28 +204,152 @@ def eventFilter(self, qobject, event):
return False

def open_perspective_popup(self):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@TimMonko this is a super badass PR! 😍

The functionality looks awesome and using the QLabeledDoubleSlider is 👌. I find it crazy that I looked away for like two seconds and you, @psobolewskiPhD and @hanjinliu have already iterated like 20 times. 😂

My only comment is now about code style: one of the principles I like to stick to is that code for a function should fit in about 1 screen vertically. (And not a crazy widescreen rotated to portrait mode. 😂) So that's about 25-50 lines. So, do you think you could break this function up to use a few helper functions/methods, so that it's easier to read at a glance? e.g. self.zoom = make_zoom_slider(...) etc.

@jni jni added enhancement highlight PR that should be mentioned in next release notes labels Feb 22, 2025
@jni jni added this to the 0.6.0 milestone Feb 22, 2025
@TimMonko
Copy link
Contributor Author

@jni, thanks for giving me this advice.
I tried two different ways to do the refactoring, in the first commit I user helper methods to create the sliders. I never prefer doing this because I feel like it requires someone to read in two places for how the slider creation works, and because of formatting requirements doesn't save space.

In the second example, I extract out just the _add_3d_camera_controls, but still create the sliders using their attributes directly.

This styling is something I struggled with (so I appreciate your feedback!) because I never like separating out too much logic from the repetitiveness. Like yeah, it repeats to constantly set the methods of the slider, but the slider is a repeated part of the gui, ya know, 5 times.

So then, if I abstract out too much its suddenly harder to understand how the code works without zooming all around the document.

Thoughts?
I certainly think I have an improvement, but add_3d_camera_controls is 'long' still

self.rx.valueChanged.connect(partial(self._update_camera_angles, 0))

self.ry = QLabeledDoubleSlider(popup)
self.ry.setRange(-89, 89)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not (-90, 90)? I am able to set that value no problem:

viewer.camera.angles
Out[1]: (np.float64(0.0), np.float64(0.0), np.float64(89.99999999999999))

viewer.camera.angles = (0, 90, 0)

viewer.camera.angles
Out[3]: (np.float64(-0.0), np.float64(90.0), np.float64(0.0))

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Try it locally with 90 and you'll see the camera gets gimbal locked, it's really strange behavior. You can prevent it by calling back the a angle changes but the sliders start zooming around and it's not intuitive

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

python_SHs4c5rIzj

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think any angle != (0,90,0) results in recalculation. ie. (0,90,90) gets converted to (-90,90,0) so it gets locked

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a fair point, though this is inevitable when doing Euler angles. I don't love that I can set the angles and then the slider is incorrect (just checked, it gets pinned at 89), but at the same time, it prevents some user issues, so 🤷 ok, we can leave it at 89. Thank you for the discussion!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really do wish we could correctly have 0,90,0 / 0,-90,0 be correct in the slider, but its the only instance where camera.angles can have a 90 y-value. It's annoying I agree, but I couldnt find a work around

Comment on lines 235 to 239
self.perspective = QLabeledDoubleSlider(popup)
self.perspective.setRange(0, 90)
self.perspective.setValue(self.viewer.camera.perspective)
self.perspective.setDecimals(0)
self.perspective.valueChanged.connect(self._update_perspective)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, so for me, the repetition is absolutely killing me. 😂 I totally get that too many layers of abstraction are annoying. My rule of thumb is that functions should be named and parametrised in such a way that you can read the code, basically trust that the function is doing what it's promising to do, and then carry on with your life. (ie you don't need to jump to another part of the code to understand it.)

This is possible with functions that are pure (input -> output), more difficult with methods with no arguments that rely on attributes in the class instance. So my advice here would be:

def labeled_double_slider(*, parent, range, initial_value, callback, decimals=0):
    slider = QLabeledDoubleSlider(parent)
    slider.setRange(*range)
    slider.setValue(initial_value)
    slider.valueChanged.connect(callback)
    slider.setDecimals(decimals)
    return slider

Then

self.perspective = labeled_double_slider(
    parent=popup,
    range=(0, 90),
    initial_value=self.viewer.camera.perspective,
    callback=self._update_perspective,
)

And so on.

The key differences with your early attempt are:

  • self isn't used, so make it a function, not a method
  • use keyword-only arguments, which makes the calls more readable

Indeed, black doesn't let us save much/any space (though the default value of decimals helps). But there's other advantages, such as helping us with an API for when/if we want to become Qt-agnostic.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I tried again!
Thanks for clarifying why to make something a function vs method -- again these are things I read but just don't make sense until its actually in practice.
Also, learned something new about the * today :)

I think you will like this next refactor more, let me know!

I definitely like how you say its important if there is goal to become Qt-agnostic. Then, my question becomes how does that work with typing? I typed all my functions and such because I find it really helps me navigate the codebase better. Does a future version without Qt use some kind of parent Type everywhere to mask whether its Qt or some other backend? (kind of like how importing qtpy stuff is either pyside or pyqt). Or do you just update arguments everywhere (seem tedious) to be like arg: QType | newType

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Then, my question becomes how does that work with typing?

we haven't got that far yet 😂

But I expect something like:

Widget = QType | IPyType | WxType | TogaType | whatever

def func(...) -> Widget

Copy link
Member
@jni jni left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❤️

@jni jni added the ready to merge Last chance for comments! Will be merged in ~24h label Feb 25, 2025
@jni jni merged commit d0c4d2c into napari:main Feb 26, 2025
41 checks passed
@github-actions github-actions bot removed the ready to merge Last chance for comments! Will be merged in ~24h label Feb 26, 2025
@psobolewskiPhD
Copy link
Member

Sorry I didn't get a chance to play with this, but why does z and x offer -180 to 180, which are identical, while the other two go from -90 to 90 (well 89)? Is that from viewer.camera?

@TimMonko
Copy link
Contributor Author

Sorry I didn't get a chance to play with this, but why does z and x offer -180 to 180, which are identical, while the other two go from -90 to 90 (well 89)? Is that from viewer.camera?

If by other two you mean perspective and y? Perspective is (0,90) from the previous behavior -- I think it could go much higher but I didn't want to change behvaior.
Y is locked to -89,90 because of trigonometry and recalculation of viewer.camera. It's more or less an unfortunate side effect of euler angle calculations. Not the nicest looking, but results in much more intuitive behavior than setting farther limits. More info in PR description:

Current behavior is to bound angles with X[-180, 180], Y(-90,90) and Z[-180,180] because values outside (-90,90) for Y result in recalculation of viewer.camera.angles. See #7626 (comment) for discussion of this implementation.

@psobolewskiPhD
Copy link
Member

Ah, sorry I wasn't clear. I just expected them all to be -90 to 90 (well 89 I guess).

@TimMonko
Copy link
Contributor Author

Ah yes fabulous point. It's so that any real camera.angle set in any way (click and drag, programmatically) is represented properly in the widget. Trig magic means we can have 180 for x and z.

@imagesc-bot
Copy link

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement highlight PR that should be mentioned in next release notes qt Relates to qt tests Something related to our tests
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2684
5 participants
0