Supported types

Built-in types

The @parameterNodeWrapper decorator keys off of the type hints given in the class definition, similar to python’s dataclasses.dataclass class.

The classes that are recognized by default are

  • int

  • float

  • str

  • bool

  • vtkMRMLNode (including subclasses)

  • list (hinted as list[int], list[str], etc)

  • tuple (hinted as tuple[int, bool], tuple[str, vtkMRMLNode, float], etc)

  • dict (hinted as dict[keyType, valueType])

  • enum.Enum

  • pathlib.Path

  • pathlib.PosixPath

  • pathlib.WindowsPath

  • pathlib.PurePath

  • pathlib.PurePosixPath

  • pathlib.PureWindowsPath

  • typing.Union (hinted as typing.Union[int, str], typing.Union[bool, vtkMRMLModelNode, float], etc)

  • typing.Optional (hinted as typing.Optional[int], typing.Optional[float], etc)

  • parameterPack (see Parameter Packs)

When using container types like list, tuple, or dict, only these types are recognized as element types by default. Note that containers can be nested, such as dict[str, list[int]].

For using custom types in parameter node wrappers, first see if Parameter Packs, will suit your needs. If not, check out the Custom Classes page.

MRML nodes

MRML nodes from non-core modules are supported, but to define a parameterNodeWrapper in the global space of a module (i.e. not inside a function) you can’t use the classes from slicer namespace. This is because they are not added to the slicer namespace until after the class is created. Here is an example on how to use vtkMRMLMarkupsFiducialNode.

from slicer.parameterNodeWrapper import *
from slicer import vtkMRMLMarkupsFiducialNode

@parameterNodeWrapper
class CustomParameterNode:
  markup: vtkMRMLMarkupsFiducialNode
  markups: list[vtkMRMLMarkupsFiducialNode]

Warning

For uses of MRML nodes in Python code, it is preferred to use slicer.<node> rather than from <specific-mrml-package> import <node> as this is more robust if MRML nodes change packages (e.g. from an extension to the core).

Enum

Instances of enum.Enum are serialized by their name, not their value. This allows enums to hold unserialized metadata, accessible via Enum.value.

from slicer.parameterNodeWrapper import *
from enum import Enum

class Color(Enum):
    RED = '#FF0000'
    GREEN = '#00FF00'
    BLUE = '#0000FF'

@parameterNodeWrapper
class CustomParameterNode:
    color: Color

It also means that if you changed the name of an enum value (e.g. from RED to Red), it would break the loading of old MRB files or MRML scenes that saved a Color parameter.

Parameter Packs

It is often useful to group related information together in structures with useful names. Another decorator, @parameterPack was added to make this easier. This will make the class behave in a similar manner to Python’s @dataclasses.dataclass. These parameterPacks can then be used in a parameterNodeWrapper. Typically the name will stored as <pack-name>.<pack-member-name> in the underlying parameter node when used with a parameterNodeWrapper.

from slicer.parameterNodeWrapper import *


@parameterPack
class Point:
    x: float
    y: float


@parameterPack
class BoundingBox:
    # can nest parameterPacks
    topLeft: Point
    bottomRight: Point


@parameterNodeWrapper
class ParameterNodeType:
    # Can add them to a @parameterNodeWrapper like any other type.
    # Will be stored in the underlying parameter node as
    #  - box.topLeft.x (default value is 0)
    #  - box.topLeft.y (default value is 1)
    #  - box.bottomRight.x (default value is 1)
    #  - box.bottomRight.y (default value is 0)
    box: BoundingBox = BoundingBox(Point(0, 1), Point(1, 0))


parameterNode = slicer.mrmlScene.AddNewNodeByClass('vtkMRMLScriptedModuleNode')
param = ParameterNodeType(parameterNode)

# can set wholesale
param.box.topLeft = Point(-4, 5)

# or can set piecewise
param.box.bottomRight.x = 4
param.box.bottomRight.y = -5

The created parameterPack will have the following attributes:

>>> from typing import Annotated
>>> from slicer.parameterNodeWrapper import *
>>>
>>> @parameterPack
>>> class ParameterPack:
>>>     # if the type is Annotated, it will treat the annotations the same as @parameterNodeWrapper
>>>     x: Annotated[float, WithinRange(0, 10)]
>>>     option: Annotated[str, Choice(["a","b"])] = "b"
>>>
>>> # with no arguments the constructor will use the given (or implied) defaults.
>>> p1 = ParameterPack()  # == ParameterPack(x=0.0, option="b")
>>>
>>> # positional arguments are accepted in the order the members are declared in
>>> p2 = ParameterPack(3.0, "a")
>>>
>>> # keyword arguments are accepted with the keyword being the member names
>>> p3 = ParameterPack(option="a", x=3.0)
>>>
>>> # unspecified arguments use their default
>>> p4 = ParameterPack(4.5)  # == ParameterPack(x=4.5, option="b")
>>> p5 = ParameterPack(option="a")  # == ParameterPack(x=0.0, option="a")
>>>
>>> # validators are run on construction
>>> p6 = ParameterPack(-1, "a")
ValueError: Value must be within range [0, 10], is -1
>>>
>>> # validators are run on set attribute
>>> p4.option = "c"
ParameterPack(x=4.5, option=b)
>>>
>>> # the classes automatically have __eq__ added to them
>>> p1 == p2
False
>>> p2 == p3
True
>>>
>>> # the classes are also given a __repr__ and a __str__ that describes their attributes
>>> print(p4)
ParameterPack(x=4.5, option="b")

If any of the autogenerated dunder methods (__init__, __eq__, __str__, __repr__) are overridden in the parameterPack, they will not be autogenerated.

@parameterPack
class ParameterPack:
    i: int
    j: int
    k: str

    # custom constructor
    def __init__(self, k):
        self.i = 1
        self.j = 4
        self.k = k

    # default __eq__, __str__, and __repr__ are generated

Parameter Packs with invariants

Invariants can be added to parameterPacks via validators as described above. However this is only good for invariants on independent parameters (you can do something like x: Annotated[int, Minimum(0)], but you can’t do something like make sure parameter x is less than another parameter y with validators).

More complex invariants can be enforced by making the parameterPack parameters private (via leading underscore convention) and then offering public accessors that enforce the invariants. While this is a little bit more work, most of the time it is still less work than making a non-parameterPack custom class work with the parameterNodeWrapper.

E.g.

from typing import Annotated
from slicer.parameterNodeWrapper import (
    parameterPack,
    Default,
)


class BadDateException(ValueError):
    pass


@parameterPack
class Date:
    # Private parameters that will be written to the scene.
    # Can still set defaults on the private parameters.
    _month: int = 1
    _day: int = 1
    _year: int = 1970

    # A checker for the multi-parameter invariant
    @staticmethod
    def checkDate(month, day, year):
        # note: this is assuming leap years don't exist and negative years are allowable
        if month < 1 or month > 12 or day < 1 or day > 31:
            raise BadDateException(f"Bad date: {month}/{day}/{year}")
        if month == 2 and day > 28:
            raise BadDateException(f"Bad date: {month}/{day}/{year}")
        if month in (4, 6, 9, 11) and day > 30:
            raise BadDateException(f"Bad date: {month}/{day}/{year}")

    # override the __init__ function to enforce the invariant
    def __init__(self, month=None, day=None, year=None) -> None:
        if month is not None:
            self._month = month
        if day is not None:
            self._day = day
        if year is not None:
            self._year = year
        self.checkDate(self._month, self._day, self._year)

    def __str__(self) -> str:
        return f"Date(month={self.month}, day={self.day}, year={self.year})"

    # Add properties that access the private parameters and enforces the invariant.
    @property
    def month(self):
        return self._month

    @month.setter
    def month(self, value):
        self.checkDate(value, self.day, self.year)
        self._month = value

    @property
    def day(self):
        return self._day

    @day.setter
    def day(self, value):
        self.checkDate(self.month, value, self.year)
        self._day = value

    @property
    def year(self):
        return self._year

    @year.setter
    def year(self, value):
        self._year = value

    # Can even add helper functions for a nicer interface.
    def setDate(self, month: int, day: int, year: int) -> None:
        self.checkDate(month, day, year)
        self._month = month
        self._day = day
        self._year = year

Warning

When trying to enforce invariants over complex classes, care needs to be taken to not allow the user to accidentally break the invariant. Consider the following code:

from slicer.parameterNodeWrapper import (
    parameterPack,
)


@parameterPack
class InvariantTestPack:
    _listA: list[int]
    _listB: list[int]

    @staticmethod
    def checkInvariant(listA, listB):
        if not len(listA) <= len(listB):
            raise ValueError("Invariant failed")

    def __init__(self, listA=None, listB=None) -> None:
        if listA is not None:
            self._listA = listA
        if listB is not None:
            self._listB = listB
        self.checkInvariant(self._listA, self._listB)

    @property
    def listA(self):
        return self._listA

    @listA.setter
    def listA(self, value):
        self.checkInvariant(value, self.listB)
        self._listA = value

    @property
    def listB(self):
        return self._listB

    @listB.setter
    def listB(self, value):
        self.checkInvariant(self.listA, value)
        self._listB = value

It is very easy for the user to accidentally break the invariant

pack = InvariantTestPack(listA=[], listB=[])

pack.listA = [1]  # raises ValueError

pack.listA.append(1)  # BAD: does not raise ValueError

The problem comes from the fact that giving direct access to listA and listB allows the user to change them without InvariantTestPack’s knowledge. This is just how Python works.

This can be worked around a couple of ways. One way is to essentially flatten the methods of list into the parameterPack (e.g. def appendListA(value) -> None, def getListA(index) -> int, def setListB(index, value) -> None). Or if the object you are returning has observation capabilities (similar to signals from Qt or AddObserver from VTK) you can try to hook into those.