Writing Algorithms

In Running Algorithms you already saw how to execute algorithm on images. This chapter will teach you how to write your own algorithm that will also show up in the ImFusionSuite GUI.

Minimal Example

An algorithm always has to derive from imfusion.Algorithm. The minimal implementation looks like this:

>>> class MyAlgorithm(imfusion.Algorithm):
...     def __init__(self):
...         super().__init__()
...
...     @classmethod
...     def convert_input(cls, data):
...         if len(data) == 0:
...             return []
...         raise IncompatibleError('Requires no input data')
...
...     def compute(self):
...         pass

Warning

Make sure that your __init__ method always starts with super().__init__(). This initializes the underlying C++ object and will lead to undefined behaviour if not called.

In order to use your new algorithm from the GUI, it has to be registered:

>>> imfusion.register_algorithm('MyAlgorithm', 'My Algorithm', MyAlgorithm)

The first argument is id, which will be automatically prepended with PYTHON. signifying the module. The second argument is the name of the algorithm as it will be used in the GUI. This algorithm then will be accessible through the SDK as PYTHON.MyAlgorithm.

The most curious method in the previous implementation is probably the imfusion.Algorithm.convert_input(). If the Algorithm is created through the framework, this classmethod is called to determine if the algorithm is compatible with the desired input data. If this method does not raise an exception, the algorithm is initialized with the data returned by convert_input(). The implementation is similar to this:

try:
    input = MyAlgorithm.convert_input(some_data)
    return MyAlgorithm(*input)
except IncompatibleError:
    return None

imfusion.Algorithm.convert_input() should only ever succeed if the given data matches exactly the allowed input for the algorithm. For example if the algorithm expects a single image, imfusion.Algorithm.convert_input() should fail if called with two images.

The exception message can be used to indicate to the user why the algorithm could not be instantiated.

You can check any property of the given data. For example, an algorithm that requires a 3D volume and a label may look like this:

>>> class MyAlgorithm(imfusion.Algorithm):
...     def __init__(image, mask):
...         super().__init__()
...
...     @classmethod
...     def convert_input(cls, data):
...         if len(data) != 2:
...             raise IncompatibleError('Requires two inputs')
...         image, mask = data
...
...         if not isinstance(image, imfusion.SharedImageSet) \
...            or image.img().dimension() != 3:
...             raise IncompatibleError('First input must be a volume')
...
...         if not isinstance(mask, imfusion.SharedImageSet) \
...            or mask.modality != ImFusion.Data.Modality.LABEL:
...             raise IncompatibleError('Second input must be a label map')
...
...         return {'image': image, 'mask': mask}
...
...     def compute(self):
...         pass

The example also returns a dictionary with keyword arguments of the constructor. This is especially useful if your algorithm has many or optional arguments.

For performance reason, you should refrain from performing any heavy computations inside imfusion.Algorithm.convert_input().

Generating output

Let’s create a slightly more useful algorithm that expects one input image and creates a thresholded version of the input as output.

>>> class MyAlgorithm(imfusion.Algorithm):
...     def __init__(self, imageset):
...         super().__init__()
...         self.imageset = imageset
...         self.imageset_out = imfusion.SharedImageSet()
...
...     @classmethod
...     def convert_input(cls, data):
...         if len(data) == 1 and isinstance(data[0], imfusion.SharedImageSet):
...             return data
...         raise IncompatibleError('Requires exactly one image')
...
...     def compute(self):
...         # clear output of previous runs
...         self.imageset_out = imfusion.SharedImageSet()
...
...         # compute the thresholding on each individual image in the set
...         for image in self.imageset:
...             arr = np.array(image) # creates a copy
...             arr[arr < 2500] = 0
...             arr[arr >= 2500] = 1
...
...             # create the output image from the thresholded data
...             image_out = imfusion.SharedImage(arr)
...             self.imageset_out.add(image_out)
...
...     def output(self):
...         return [self.imageset_out]
>>> imfusion.unregister_algorithm('PYTHON.MyAlgorithm') # remove the old version
>>> imfusion.register_algorithm('MyAlgorithm', 'My Algorithm', MyAlgorithm)

Let’s try it:

>>> image = imfusion.load('ct_image.png')[0]
>>> output = imfusion.execute_algorithm('PYTHON.MyAlgorithm', [image])[0]
>>> np.min(np.array(output[0]))
0
>>> np.max(np.array(output[0]))
1

Adding Parameters

Having the thresholding value hardcoded to 2500 is not very useful. Luckily, the ImFusion framework provides a powerful dynamic property system that can also generate basic UIs automatically. To use it, you just have to declare a parameter with imfusion.Algorithm.add_param():

>>> class MyAlgorithm(imfusion.Algorithm):
...     def __init__(self, imageset):
...         super().__init__()
...         self.imageset = imageset
...         self.imageset_out = imfusion.SharedImageSet()
...
...         self.add_param('threshold', 2500,
...                        attributes='min: 0, max: 5000')
...
...     @classmethod
...     def convert_input(cls, data):
...         if len(data) == 1 and isinstance(data[0], imfusion.SharedImageSet):
...             return data
...         raise IncompatibleError('Requires exactly one image')
...
...     def compute(self):
...         # clear output of previous runs
...         self.imageset_out = imfusion.SharedImageSet()
...
...         for image in self.imageset:
...             arr = np.array(image)
...             arr[arr < self.threshold] = 0
...             arr[arr >= self.threshold] = 1
...
...             image_out = imfusion.SharedImage(arr)
...             self.imageset_out.add(image_out)
...
...     def output(self):
...         return [self.imageset_out]
>>> imfusion.unregister_algorithm('PYTHON.MyAlgorithm') # remove the old version
>>> imfusion.register_algorithm('MyAlgorithm', 'My Algorithm', MyAlgorithm)

The imfusion.Algorithm.add_param() method will add a dynamic attribute called like the first argument (in this case threshold). The second argument is the initial value of the parameter and the optional attributes argument allows you to modify certain attributes of the automatically generated UI. See the C++ documentation for details.

Adding Actions

Algorithm actions are a way to interactively call methods other than compute. When an action is registered to an algorithm, a button is added to its controller. The action will be executed when the user clicks on this button. Methods are automatically registered as actions when they have the imfusion.Algorithm.action decorator. These methods should return an Algorithm.Status code or nothing (in which case, a Algorithm.Status.UNKNOWN will be implicitly returned).

>>> class CountingAlgorithm(imfusion.Algorithm):
...     def __init__(self):
...         super().__init__()
...
...         self.counter = 0
...
...     @imfusion.Algorithm.action(display_name="Increment Counter")
...     def increment_counter(self):
...         self.counter += 1
...         print('Incremented counter!')
...         return imfusion.Algorithm.Status.SUCCESS
...
...     @classmethod
...     def convert_input(cls, data):
...         if len(data) == 0:
...             return data
...         raise imfusion.IncompatibleError('Requires no data')
...
...     def compute(self):
...         print(f"Counter = {self.counter}")
>>> imfusion.unregister_algorithm('PYTHON.CountingAlgorithm')
>>> imfusion.register_algorithm('CountingAlgorithm', 'My Counting Algorithm', CountingAlgorithm)

It is possible to check the list of registered actions via the property imfusion.Algorithm.actions().