alliedvision / VimbaPython

Old Allied Vision Vimba Python API. The successor to this API is VmbPy
BSD 2-Clause "Simplified" License
93 stars 40 forks source link

Manually managing Instance and Camera contexts #116

Open jeanchristopheruel opened 2 years ago

jeanchristopheruel commented 2 years ago

Allow the user to avoid using the "with" statement to manually manage contexts.

As mentioned by some users, it seems like the "with" statement is not the best choice of design for applications where the context manager need to be accessed across multiple threads. See #47 and #48.

I suggest to refactor the vimba Instance (vimba.vimba.Vimba._Vimba__Impl) and Camera (vimba.Camera) to allow the user to manually manage contexts. I would also improve vimba.vimba.Vimba's typing formats (ex: vimba.vimba.Vimba._Vimba__Impl -> vimba.Instance).

For example:

vimba_instance: vimba.Instance = vimba.get_global_vimba_instance()
vimba_instance.reset()  # Reset resources allocation
vimba_instance.start()
camera1: vimba.Camera = vimba_instance.get_camera_by_id(camera1_id)
camera2: vimba.Camera = vimba_instance.get_camera_by_id(camera2_id)
cameras: typing.List[vimba.Camera] = [camera1, camera2]
for cam in cameras:
    cam.start()
...
for cam in cameras:
    cam.stop()
vimba_instance.stop()
NiklasKroeger-AlliedVision commented 2 years ago

The context manager is a great tool provided by python to provide a runtime context to some variables. It makes it possible to ensure that, even in the case of errors inside the context, some sort of cleanup takes place. In that regard it can be considered similar to the CPP concept or RAII. It is used with some of the core classes in VimbaPython to perform this exact task, ensure that the Vimba context is entered and the VmbStartup/VmbShutdown functions of our C-API are called properly, and to make sure the camera connection is handled appropriately across the lifetime of the Camera instance.

From my personal experience it is usually possible to design software in such a way, that the context manager can be used to handle these tasks nicely. For multithreaded operations for example entering the Cameras context manager in a core thread that runs some kind of "producer" class might be a good approach (see e.g. the multithreading_opencv.py example). But I do understand that this might not fit all use cases.

Closer description of the context manager and how it works

Pythons implementation of the context manager can be considered syntactic sugar for something like the following, where instance is our variable for which we want to enter the context manager.

try:
    instance.enter_context_manager()
    instance.do_something()
finally:
    instance.leave_context_manager()

This will basically do the same thing I described above:

For the actual implementation of Pythons context managers, these enter_context_manager and leave_context_manager methods use the "magic" names __enter__ and __exit__. See also PEP-343.

Manually managing the context

With that in mind, the current implementation of VimbaPython actually already makes it possible to do this manual managing of the context. But it requires the user to call these "magic" methods manually. Here is an example how it could be used. Note that managing the context manually is not recommended or intended by us and could lead to hard to debug issues.

class Camera:
    def __init__(self) -> None:
        self.context_entered = False

    def __enter__(self):
        print('Entering the context manager')
        self.context_entered = True
        return self

    def __exit__(self, exc_type, exc_value, exc_traceback):
        print('Leaving the context manager')
        self.context_entered = False

    def do_something(self):
        if self.context_entered:
            print('Successfully did something')
        else:
            print('Failed to do something. Context was not entered')

print('A'*50)
print('Using the context manager normally')
with Camera() as cam:
    cam.do_something()
    cam.do_something()

print('\n' + 'B'*50)
print('Using the context manager but getting an exception')
try:
    with Camera() as cam:
        cam.do_something()
        raise Exception(f'Something went wrong!')
        cam.do_something()
except Exception as e:
    # Ignore the exception so the other examples still run
    print(f'Exception was caught: {e}')

print('\n' + 'C'*50)
print('Manually managing context')
cam = Camera()
cam.__enter__()
cam.do_something()
cam.do_something()
cam.__exit__(None, None, None)

print('\n' + 'D'*50)
print('Manually managing context but getting an exception')
cam = Camera()
try:
    cam.__enter__()
    cam.do_something()
    raise Exception(f'Something went wrong!')
    cam.do_something()
    cam.__exit__(None, None, None)
except Exception as e:
    # Ignore the exception so the other examples still run
    print(f'Exception was caught: {e}')
print(f'WARNING: Context of cam was never closed! cam.context_entered={cam.context_entered}')

print('\n' + 'E'*50)
print('Manually managing context but getting an exception with proper handling of __exit__')
cam = Camera()
try:
    cam.__enter__()
    cam.do_something()
    raise Exception(f'Something went wrong!')
    cam.do_something()
except Exception as e:
    # Ignore the exception so the other examples still run
    print(f'Exception was caught: {e}')
finally:
    cam.__exit__(None, None, None)
print(f'This time context of cam was closed properly thanks to the `finally` block. cam.context_entered={cam.context_entered}')

The output should show the following to demonstrate the intricacies of the different uses

AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
Using the context manager normally
Entering the context manager
Successfully did something
Successfully did something
Leaving the context manager

BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB
Using the context manager but getting an exception
Entering the context manager
Successfully did something
Leaving the context manager
Exception was caught: Something went wrong!

CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC
Manually managing context
Entering the context manager
Successfully did something
Successfully did something
Leaving the context manager

DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD
Manually managing context but getting an exception
Entering the context manager
Successfully did something
Exception was caught: Something went wrong!
WARNING: Context of cam was never closed! cam.context_entered=True

EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE
Manually managing context but getting an exception with proper handling of __exit__
Entering the context manager
Successfully did something
Exception was caught: Something went wrong!
Leaving the context manager
This time context of cam was closed properly thanks to the `finally` block. cam.context_entered=False

As you can see the cases B and E are almost the same. The only difference is in the order in which the caught exception are handled and the context manager are closed. This is just because the except part of the try ... except ... finally block is executed before the finally part. For practical use I do not think that this should have much of an impact.

Summary

PS: Thank you for the input on this. You seem to be quite knowledgeable in these more detailed design choices. Do you have a suggestion how we could better allow for both paradigms to be used, with context manager and manual management?

jeanchristopheruel commented 2 years ago

Thank you so much so your fast and valuable reply !

In multithread_opencv at line 131, __call__ has a direct dependency to the camera context manager, which breaks the encapsulation principle.

The with statement prohibit the user from defining granular exception catching based on the type. See Catching an exception while using a Python 'with' statement

In order to deal with encapsulation and granular exception catching, I would personally prefer something like:

import vimba 
import threading
import queue
import typing

class Camera(threading.Thread):
    """Frame producer thread"""
    _frame_queue: queue.Queue
    _vimda_cam: vimba.Camera

   def __init__(self, vimba_instance: vimba.vimba.Vimba._Vimba__Impl, ...) -> None:
        # Do something like assert vimba_instance.is_running()
        super().__init__()
        self._frame_queue = queue.Queue()
        try:
             self._vimba_cam = vimba_instance.get_camera_by_id(...)
        except ...:
              ...
        finally:
              ...
    ...

    @utils.override
    def set_up(self) -> None:
        """Called just before main thread enters"""
        self._vimba_cam._open()
        _configure_camera(self._vimba_cam, ...)

    @utils.override
    def teardown(self) -> None:
        """Called just after main thread exits"""
        try:
             self._vimba_cam._close()
        except ...:
              ...
        finally:
              ...

    @utils.override
    def run(self) -> None:
        def frame_handler(cam: vimba.Camera, frame: vimba.Frame) -> None:
            if frame.get_status() == vimba.FrameStatus.Complete:
                self._last_frame.set_value(frame.as_numpy_ndarray())
                cam.queue_frame(frame)

        self._vimba_cam.start_streaming(handler=frame_handler, buffer_count=10)
        while ...:
            time.sleep(0.001)
            pass
        self._vimba_cam.stop_streaming()
        self.reset_frame()

def main() -> None:
    try:
          vimba_instance: vimba.vimba.Vimba._Vimba__Impl = vimba.Vimba.get_instance() 
          vimba_instance._startup()
    except ...:
          ...
    finally:
          ...

    cameras: typing.List[Camera] = []
    for ... in ...:
        cameras.append( Camera(vimba_instance, ...) )

    for cam in cameras:
        try:
              cam.start()
        except ...:
              ...
        finally:
              ...

    while ...:
        # Hardware triggering
        gpio_trigger.on()
        for cam in cameras:
            cam.wait_for_new_frame(timeout=2.0)
        gpio_trigger.off()

        for cam in cameras:
            if cam.is_frame_available():
                frame = cam.get_frame()
                ...

    for cam in cameras:
        try:
              cam.stop(blocking=True, timeout=1.0)
        except ...:
              ...
        finally:
              ...

    try:
          vimba_instance._shutdown()
    except ...:
          ...
    finally:
          ...

The thing is that if something goes wrong with the vimba resource deallocation, I could not find a way of recovering appart from restarting the usb controller. (Related to #117 ). I would suggest to make some kind of daemon driver that can handle multiple processes instead of the vimba instance singleton.

Regarding the __enter__ and __exit__ methods, since they are both special functions, it is not conventional to let the user explicitly call them. One would expect the class to have a public interface. Same thing would apply for _open and _close as the prefix underscore is usually meant to declare a private function.

Finally, I understand that my needs may not represent those of an average customer. I am trying to make a multi-sensor IoT camera with fully containerized software. I really enjoy working with your cameras and I think you had an excellent idea by hosting you API as an open source repo on github ! Keep up the good work !