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 aslist[int]
,list[str]
, etc)tuple
(hinted astuple[int, bool]
,tuple[str, vtkMRMLNode, float]
, etc)dict
(hinted asdict[keyType, valueType]
)enum.Enum
pathlib.Path
pathlib.PosixPath
pathlib.WindowsPath
pathlib.PurePath
pathlib.PurePosixPath
pathlib.PureWindowsPath
typing.Union
(hinted astyping.Union[int, str]
,typing.Union[bool, vtkMRMLModelNode, float]
, etc)typing.Optional
(hinted astyping.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 parameterPack
s 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 parameterPack
s 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.