Skip to content

Conversation

Baharis
Copy link
Member

@Baharis Baharis commented Mar 25, 2025

Click dispatcher

Instamatic GUI is build out of multiple modules that are almost completely independent, yet can communicate thanks to the ingenuity of the AppLoader. This class does not only load modules but also allows them to access each other via get_module(). Currently, this functionality is used only in three places (to define GUI self.module_io, to save_image, to dynamically modify the ExperimentalSED). Here I suggest another use: to add clicking interactions with the VideoStreamFrame.

This PR adds a new ClickDispatcher to the VideoStreamFrame. By adding self.panel.bind('<Button>', self.on_click) and a few lines to a formerly-empty built-in tkinter on_click(), the ClickDispatchercan handle clicks on the decorated frame and call callbacks without any performance overhead. Since this might sound obscure, here are some examples. Firstly, adding the following to any frame makes the console print the coordinates of clicked pixel:

print('Starting now, clicking on image prints click coordinates')
s = self.app.get_module('stream')                              # grab the running VideoStreamFrame instance
c = lambda e: print(e)                                         # define the callback function
v = s.click_dispatcher.add_listener(name='video', callback=c)  # makes and registers the "listener"
v.active = True                                                # start the "listener"

Screenshot 2025-03-25 235121

If we expect our listener to be active only briefly, instead of defining callback we can use it as a context to wait for clicks:

print('Please click on the mesh grid bar to define background level')
with s.click_dispatcher.add_listener('temp') as tl:
    click = tl.get_click()
    image = self.app.get_module('stream').frame
    bg_level = image[click.y, click.x]

Using both callback and context at the same time is also possible for some cool interactive applications:

print('Left-click on the image to move the beam; right-click to cancel.')
cancel = threading.Event()
bm = s.click_dispatcher.add_listener('beam_mover', callback=lambda e: cancel.set() if e.button != 1 else None) 
calib = CalibBeamShift.from_file()
while not cancel.is_set():
    with bm:
        click = bm.get_click()
        new_beamshift = calib.pixelcoord_to_beamshift(click.x, click.y)
        self.ctrl.beamshift.set(*new_beamshift)

As shown above, listeners can be accessed via click_dispatcher.listeners NoOverwriteDict (in new instamatic.collections). Here, I only introduce the class and showcase its potential uses - some of which I plan to include in the following PRs.

Typing extensions

While adding new features is fun, improving the maintainability of existing ones it also important. For this reason, in this PR I also suggest adding typing_extenstions a new instamatic dependency. typing_extensions is Guido von Ross's light-weight dependency-less python library (i.e. a single file) that back-ports new python typing types to old python versions. While typically I'd be against adding new dependencies, this one is literally in the top ten most downloaded ones and has little drawbacks.

To exemplify the usefulness of typing_extensions, in this PR I improved the type hints for a bunch of constructors by specifying their return type using Self. The Self type hint is a python 3.11 addition that says the return type of a cls.method is the same as cls's. The Python 3.7-3.10 way of defining Self is rarely done well. One great use by @viljarjf is actually present in simulation.py:

Crystal_T = TypeVar('Crystal_T', bound='Crystal')
class Crystal:
    @classmethod
    def default(cls: Type[Crystal_T]) -> Crystal_T:
        return cls(1, 2, 3, 90, 100, 110)

While I did not modify this particular file to not cause potential merging issues for him, Self would simplify that to:

class Crystal:
    @classmethod
    def default(cls) -> Self:
        return cls(1, 2, 3, 90, 100, 110)

Major changes

  • New self.app.get_module('stream').click_dispatcher can handle user clicking on the VideoStreamFrame image;

Code maintanance

  • Introduce typing_extension dependency to vastly expand typing capabilities, improving code maintenance;
  • instamatic._collections is a new module for repeated design patterns, e.g. new NoOverwriteDict.

Effect on the code base

The new ClickDispatcher can be used on VideoStreamFrame or other frames (if implemented) to vastly improve the level of interaction between the user and GUI. With the typing_extension added, the developers can now use typing type hints accessible to Python3.8+ which, otherwise, would break python 3.7 compatibility.

@Baharis Baharis added enhancement dependencies Pull requests that update a dependency file labels Mar 25, 2025
@Baharis Baharis requested a review from stefsmeets March 25, 2025 23:10
@Baharis Baharis self-assigned this Mar 25, 2025
@Baharis
Copy link
Member Author

Baharis commented Mar 25, 2025

Pinging @viljarjf because I think he will appreciate addition of typing_extensions ☺️

@Baharis
Copy link
Member Author

Baharis commented Mar 25, 2025

Disambiguation on Self vs TypeVar

For nerds like me and potential time travelers, Self suggests that a method returns exactly the same type as class. Furthermore, for any class that inherits from CameraBase, Self becomes the type of said child class, and then __enter__ returns indeed self of the subclass type. So because CameraBase.__enter__ would always return CameraBase-type object and CameraSubclass.__enter__ a CameraSubclass-type object, the following is correct:

class CameraBase(ABC):
    def __enter__(self) -> Self:
        self.establish_connection()
        return self

However, whenever one class might return another, the Self syntax is no longer correct. For example, in the VideoStream, it is better to say that VideoStream.from_any returns TypeVar('VideoStream_T', bound='VideoStream') (i.e. any subclass of VideoStream) because there VideoStream.from_any might produce FakeVideoStream - a different class:

VideoStream_T = TypeVar('VideoStream_T', bound='VideoStream')  # VideoStream or subclass
class VideoStream(threading.Thread):
    @classmethod
    def from_any(cls: Type[VideoStream_T], cam: Union[CameraBase, str] = 'simulate') -> VideoStream_T:
        """Create a subclass based on passed cam or cam-str stream-ability."""
        cam: CameraBase = Camera(name=cam) if isinstance(cam, str) else cam
        if cls is VideoStream:
            return (LiveVideoStream if cam.streamable else FakeVideoStream)(cam)
        return cls(cam)

Now for the most fascinating case: VideoStream.__new__ seemingly ALWAYS produces the same type as class that calls it: VideoStream('any_cam') will raise NotImplementedError and FakeVideoStream('any_cam') will always produce FakeVideoStream. However, because __new__ is a class method (!) specifically defined for VideoStream class, not an instance method like __enter__ that interprets Self type based on passed instance self, it should use a generic VideoStream_T return type!

VideoStream_T = TypeVar('VideoStream_T', bound='VideoStream')  # VideoStream or subclass
class VideoStream(threading.Thread):
    def __new__(cls: Type[VideoStream_T], *args, **kwargs) -> VideoStream_T:
        if cls is VideoStream:
            msg = 'Initialize a `VideoStream` via its subclasses or using `.from_any()`.'
            raise NotImplementedError(msg)
        return super(VideoStream, cls).__new__(cls)

Does this matter? Unless you have OCD or ASD, probably not, but I love exploring typing details like this.

@stefsmeets
Copy link
Member

Unless you have OCD or ASD, probably not, but I love exploring typing details like this.

There are 2 type[people] in this world, those who enjoy types and those who mypy 😅

Copy link
Member

@stefsmeets stefsmeets left a comment

Choose a reason for hiding this comment

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

Looks good to me for the most part, just wondering if the NoOverwriteDict is necessary.

"""Manages `ClickEvent` distribution across `ClickListeners`."""

def __init__(self):
self.listeners: NoOverwriteDict[str, ClickListener] = NoOverwriteDict()
Copy link
Member

Choose a reason for hiding this comment

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

Is the NoOverwriteDict() necessary?

Copy link
Member Author

Choose a reason for hiding this comment

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

It is not but in my opinion it makes things more clear, see comment below.

) -> ClickListener:
"""Convenience method that adds and returns a new `ClickListener`"""
listener = ClickListener(name, callback)
self.listeners[name] = listener
Copy link
Member

Choose a reason for hiding this comment

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

Seems like the logic for NoOverwriteDict could instead be here:

Suggested change
self.listeners[name] = listener
if name in self.listeners:
raise KeyError(f'Listener "{name}" already exists and cannot be overwritten.')
self.listeners[name] = listener

Copy link
Member Author

@Baharis Baharis Mar 27, 2025

Choose a reason for hiding this comment

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

It absolutely could, but then let me put the question the other way around - why would you write:

if name in self.listeners:
    raise KeyError(f'Listener "{name}" already exists and cannot be overwritten.')
self.listeners[name] = listener

each time if you can just compartmentalize that don't-overwrite feature into a simple class and use anywhere you don't want overwriting, simply by writing:

self.listeners[name] = listener  # and the class assets key is not overwritten

That's kind of the principle of OOP. I personally think a few of existing instamatic dicts (i.e. module, frame, camera) should never allow overwriting and should be replaced with a NoOverwriteDict to avoid potential issues.

All in all its a matter of preference, and in my opinion this makes the logic more clear, as the "don't overwrite keys" is not very relevant to the listener, so why focus on that there?

Copy link
Member

Choose a reason for hiding this comment

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

Yeah, I agree. I'm just wondering if this isn't a case of premature optimization.

Copy link
Member Author

Choose a reason for hiding this comment

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

I will merge with the NoOverwriteDict for the time being, I have useful objects in mind for the instamatic._collections planned. And if they fail, it can be drop-in replaced with a (more error prone) standard dict.

@Baharis Baharis merged commit ebd6d12 into instamatic-dev:main Mar 27, 2025
7 checks passed
@Baharis Baharis deleted the click_service branch March 27, 2025 18:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
dependencies Pull requests that update a dependency file enhancement
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants