Skip to content
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

refactor: Pass LUTModel to LUTView #87

Merged
merged 39 commits into from
Feb 5, 2025
Merged

Conversation

gselzer
Copy link
Collaborator

@gselzer gselzer commented Jan 12, 2025

This PR is designed to simplify the current MVC architecture of the lookup tables. This work was originally part of a larger PR that does similar things to all of the model-view pairings, but there's no reason it cannot stand on its own and be scrutinized earlier. In other words, it's nice to discuss whether this is a good idea before applying this design to the rest of the codebase.

As noted in #70, the resulting design is simpler, removing signals from the LUTView objects. They instead maintain access to a LUTModel object, which is updated directly. Much of the boilerplate logic in the ChannelController thus goes away, and it simply becomes an organizer for one LUTModel and many LUTViews (which in practice is a LUT widget, an image handle, and sometimes a histogram)

One additional benefit here is that testing LUTModel and LUTView should become much easier without the controller being needed. I'd like to add some of these tests as a part of this PR, once we decide we like the changes.

TODO:

  • Reconcile ImageHandle and LUTView. It seems to me like ImageHandle should be a LUTView, however there are a few methods brought in from the Viewable ABC that are strange here. There's a bit of naming skew between them though, which would be nice to resolve.
  • There are many if self.model checks, which I find annoying. They exist because the LUTView does not require the model at construction time - this would require us pass a LUTModel to functions on e.g. an ArrayView, which I found strange. But maybe that's less annoying?
  • Clean
  • Test

@gselzer gselzer added the enhancement New feature or request label Jan 12, 2025
@gselzer gselzer requested a review from tlambert03 January 12, 2025 04:04
@gselzer gselzer self-assigned this Jan 12, 2025
@tlambert03
Copy link
Member

i took the liberty of fixing merge conflicts (which were caused by my docs stuff in #84)

Copy link
Member

@tlambert03 tlambert03 left a comment

Choose a reason for hiding this comment

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

this is great @gselzer. I really like the direction this is heading!

There's one important thing that's been lost though (and it looks like I hadn't added a test for this yet) and that is the synchronization between auto-scale and clims:

  1. manually setting the clims needs to turn off autoscaling
  2. manually toggling autoscale (when it was previously off) needs to update the clims slider
Screen.Recording.2025-01-12.at.11.23.32.AM.mov

this is a somewhat tricky thing to actually do, because it can cause circular signals (e.g. setting autoscale=True on the model will set clims to the autoscaled version ... but that should not result in model.autoscale=False. By contrast, if the user sets clims (as opposed to the action of autoscale), then we do need to set model.autoscale = False). For example, on main:

Untitled.mov

one thing I fear may happen with this PR is that that complicated behavior now needs to be re-implemented on each frontend view? whereas those set_without_signal methods were previously handling that for all views (but I'm not sure yet)

@tlambert03
Copy link
Member

it looks like I hadn't added a test for this yet

if it would help, I can try to add this test to main?

@gselzer
Copy link
Collaborator Author

gselzer commented Jan 12, 2025

There's one important thing that's been lost though (and it looks like I hadn't added a test for this yet) and that is the synchronization between auto-scale and clims:

1. manually setting the clims needs to turn off autoscaling

2. manually toggling autoscale (when it was previously off) needs to update the clims slider

Yeah, good catch, let me try to work this in tomorrow.

one thing I fear may happen with this PR is that that complicated behavior now needs to be re-implemented on each frontend view? whereas those set_without_signal methods were previously handling that for all views (but I'm not sure yet)

Well I hope a lot of the logic can go within the LUTView class itself. Some of the autoscale behavior could be tricky and may need to remain within the controller (or maybe it could live within the ImageHandle?), but we should be aware of whether this design is actually worth it as we work out the remaining kinks.

it looks like I hadn't added a test for this yet

if it would help, I can try to add this test to main?

If you want to, and it's not a lot of effort, then sure!

@gselzer
Copy link
Collaborator Author

gselzer commented Jan 13, 2025

There's one important thing that's been lost though (and it looks like I hadn't added a test for this yet) and that is the synchronization between auto-scale and clims:

1. manually setting the clims needs to turn off autoscaling

2. manually toggling autoscale (when it was previously off) needs to update the clims slider

This has been fixed with 9abec15.

one thing I fear may happen with this PR is that that complicated behavior now needs to be re-implemented on each frontend view? whereas those set_without_signal methods were previously handling that for all views (but I'm not sure yet)

Yeah, I mean there's a little bit - let me know if you think the changes are not worth it. I think that the code added to jupyter is the worst - if you know of a better way to handle that code, let me know!

It's worth noting that for wx, it seems like the default SetValue behavior does not fire off events (see ToggleButton, CheckBox), so I removed the event code from the RangeSlider. Notably, I had trouble testing it, because I can't see the range slider:

image

Copy link

codecov bot commented Jan 13, 2025

Codecov Report

Attention: Patch coverage is 86.95652% with 21 lines in your changes missing coverage. Please review.

Project coverage is 76.41%. Comparing base (1c7658e) to head (7d9f4b2).
Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
src/ndv/views/_vispy/_histogram.py 33.33% 4 Missing ⚠️
src/ndv/views/bases/_lut_view.py 86.20% 4 Missing ⚠️
src/ndv/views/_pygfx/_histogram.py 40.00% 3 Missing ⚠️
src/ndv/controllers/_channel_controller.py 90.00% 2 Missing ⚠️
src/ndv/models/_lut_model.py 75.00% 2 Missing ⚠️
src/ndv/views/_wx/_array_view.py 86.66% 2 Missing ⚠️
src/ndv/views/bases/_graphics/_canvas_elements.py 80.00% 2 Missing ⚠️
src/ndv/v1/_qt/_lut_control.py 66.66% 1 Missing ⚠️
src/ndv/views/_pygfx/_array_canvas.py 75.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main      #87      +/-   ##
==========================================
+ Coverage   75.76%   76.41%   +0.64%     
==========================================
  Files          49       49              
  Lines        4952     4956       +4     
==========================================
+ Hits         3752     3787      +35     
+ Misses       1200     1169      -31     

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

Since the actual value to set here is gathered from the data, not from
the user, it shouldn't really be on the view. Also will make things much
easier when multiple handles have different data.
The use of a controller was causing failures in some places.
@tlambert03
Copy link
Member

just took another look here. As mentioned in conversations on zulip, I think the current deal breaker for me here is what this change, as written, implies for what it means to write views. It moves us away from a pattern where all one has to do in order to implement a frontend is simply add a few setter methods for each property, and into a place where each view has to carefully manage their own events, in their own pattern, in order to update the model in the correct way. To me, that opens up both the possibility that different views behave quite differently from each other, and it also puts a bigger burden on what it means to add additional fields to the model, and to add new possible views. It does make the controller now look very nicely slim, but the slimness of that 1 controller comes at the expense of the fatness of all the views.

I definitely agree we haven't yet landed on particularly nice pattern in main either, but i also don't think i'd be happier going in this direction.

as a side-note: it's conceivable that #94 might have removed the need for all of the blockers that are currently in place (and which you are trying hard to maintain in this PR). The reason would be that it removed the one thing that caused us to need to know who was updating the model (i.e. whether it was the view or the user). With that gone, we may well never have to worry about vicious loops.

@gselzer
Copy link
Collaborator Author

gselzer commented Feb 3, 2025

Into a place where each view has to carefully manage their own events, in their own pattern, in order to update the model in the correct way.

Yeah, that is also the largest drawback for me. It is nice to be able to abstract the different event intricacies behind psygnal.

To me, that opens up both the possibility that different views behave quite differently from each other and it also puts a bigger burden on what it means to add additional fields to the model, and to add new possible views.

I don't think I understand what you're worried about here, can you give an example?

The only additional burden here is that the LutViews must avoid side-effects, as we cannot rely on psygnal to avoid them. It is a bit more code, but it is also a cleaner contract and simpler control flow. Adding additional fields to the model should be equally difficult either way (although we could make that easier by adding no-op implementations to the view ABC).

I definitely agree we haven't yet landed on particularly nice pattern in main either, but i also don't think i'd be happier going in this direction.

I do think that this design simplifies control flow, simplifying debugging and maintenance, but if you feel strongly here then I'm content to move on from this experiment!

Copy link
Member

@tlambert03 tlambert03 left a comment

Choose a reason for hiding this comment

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

ok, I think i'm having a change of heart here :)

I sat down to pick out the parts i liked from the parts i didn't, and honestly really wasn't left with too much besides the front-end views, and even those weren't as much as i thought they were. (again, apologies for my waffling on this). I left a suggestion for cleaning up the jupyter pattern, we can likely add one to wx later too)

in addition to the other comments , I think if we added __eq__ operators to ClimPolicy, we would avoid a lot of unnecessary signals (which only get emitted when the value is not equal to the previous value):

class ClimsManual(ClimPolicy):
    ...
    def __eq__(self, other: object) -> bool:
        return (
            isinstance(other, ClimsManual)
            and self.min == other.min
            and self.max == other.max
        )


class ClimsMinMax(ClimPolicy):
    ...
    def __eq__(self, other: object) -> bool:
        return isinstance(other, ClimsMinMax)


class ClimsPercentile(ClimPolicy):
    ...
    def __eq__(self, other: object) -> bool:
        return (
            isinstance(other, ClimsPercentile)
            and self.min_percentile == other.min_percentile
            and self.max_percentile == other.max_percentile
        )


class ClimsStdDev(ClimPolicy):
    ...
    def __eq__(self, other: object) -> bool:
        return (
            isinstance(other, ClimsStdDev)
            and self.n_stdev == other.n_stdev
            and self.center == other.center
        )

would you mind integrating those and then marking this not-draft when ready? i think it's likely good to go at that point

@gselzer
Copy link
Collaborator Author

gselzer commented Feb 4, 2025

in addition to the other comments , I think if we added __eq__ operators to ClimPolicy, we would avoid a lot of unnecessary signals (which only get emitted when the value is not equal to the previous value):

Oh, yeah, there are some nice ideas here! Let me take a look!

@tlambert03 tlambert03 changed the title Pass LUTModel to LUTView refactor: Pass LUTModel to LUTView Feb 4, 2025
@gselzer gselzer marked this pull request as ready for review February 4, 2025 23:26
Copy link
Member

@tlambert03 tlambert03 left a comment

Choose a reason for hiding this comment

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

good stuff. really quite lovely, i don't know what i was smoking before 😂

only some minor comments

@tlambert03
Copy link
Member

the fails are unrelated... gotta figure out how to get us back to robust CI tests. ... but later. thanks for this!

@tlambert03 tlambert03 merged commit 23b050a into pyapp-kit:main Feb 5, 2025
48 of 52 checks passed
@gselzer gselzer mentioned this pull request Feb 11, 2025
2 tasks
@tlambert03 tlambert03 mentioned this pull request Feb 21, 2025
4 tasks
@tlambert03 tlambert03 removed the enhancement New feature or request label Feb 24, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants