Skip to content

Mesh 3D

3D surface mesh generation from segmentation volumes and snapshot rendering.

Snapshot 3D

TPTBox.mesh3D.snapshot3D

make_snapshot3D

make_snapshot3D(img: Image_Reference, output_path: str | Path | None, view: VIEW | list[VIEW] = 'A', ids_list: list[Sequence[int]] | None = None, smoothing: int = 20, resolution: float | None = None, width_factor: float = 1.0, scale_factor: int = 1, verbose: bool = True, crop: bool = True, png_magnify: int = 1) -> Image.Image

Generate a 3D surface-rendered snapshot from a segmentation image.

Renders each label in the segmentation with its ITK color, arranges the specified views side-by-side, and saves the composite image as a PNG.

Parameters:

Name Type Description Default
img Image_Reference

Source segmentation image reference (NIfTI path, NII, etc.).

required
output_path str | Path | None

Destination PNG path. If None, a temporary file is used and the resulting image is returned without being saved permanently.

required
view VIEW | list[VIEW]

Camera direction(s) for the render. Accepts a single direction string or a list. Valid values: "R", "A", "L", "P", "S", "I".

'A'
ids_list list[Sequence[int]] | None

Per-view lists of label IDs to render. If None, all unique non-zero labels are used for every view.

None
smoothing int

Number of VTK smoothing iterations applied to each surface.

20
resolution float | None

Isotropic voxel size (mm) to resample to before rendering. Defaults to the minimum zoom of the image.

None
width_factor float

Multiplier applied to the per-view pixel width.

1.0
scale_factor int

PNG magnification factor passed to fury's record function.

1
verbose bool

If True, logs the output path after saving.

True
crop bool

If True, crops the image to its bounding box before rendering.

True
png_magnify int

Window pixel density multiplier for the fury renderer.

1

Returns:

Type Description
Image

The rendered snapshot as a PIL Image object.

Source code in TPTBox/mesh3D/snapshot3D.py
def make_snapshot3D(
    img: Image_Reference,
    output_path: str | Path | None,
    view: VIEW | list[VIEW] = "A",
    ids_list: list[Sequence[int]] | None = None,
    smoothing: int = 20,
    resolution: float | None = None,
    width_factor: float = 1.0,
    scale_factor: int = 1,
    verbose: bool = True,
    crop: bool = True,
    png_magnify: int = 1,
) -> Image.Image:
    """Generate a 3D surface-rendered snapshot from a segmentation image.

    Renders each label in the segmentation with its ITK color, arranges the
    specified views side-by-side, and saves the composite image as a PNG.

    Args:
        img: Source segmentation image reference (NIfTI path, NII, etc.).
        output_path: Destination PNG path. If None, a temporary file is used and
            the resulting image is returned without being saved permanently.
        view: Camera direction(s) for the render. Accepts a single direction
            string or a list. Valid values: ``"R"``, ``"A"``, ``"L"``, ``"P"``,
            ``"S"``, ``"I"``.
        ids_list: Per-view lists of label IDs to render. If None, all unique
            non-zero labels are used for every view.
        smoothing: Number of VTK smoothing iterations applied to each surface.
        resolution: Isotropic voxel size (mm) to resample to before rendering.
            Defaults to the minimum zoom of the image.
        width_factor: Multiplier applied to the per-view pixel width.
        scale_factor: PNG magnification factor passed to fury's record function.
        verbose: If True, logs the output path after saving.
        crop: If True, crops the image to its bounding box before rendering.
        png_magnify: Window pixel density multiplier for the fury renderer.

    Returns:
        The rendered snapshot as a PIL Image object.
    """
    is_tmp = output_path is None
    t = None
    if output_path is None:
        t = NamedTemporaryFile(suffix="_snap3D.png")  # noqa: SIM115
        output_path = str(t.name)
    Path(output_path).parent.mkdir(exist_ok=True, parents=True)
    nii = to_nii_seg(img)
    if crop:
        try:
            nii.apply_crop_(nii.compute_crop(dist=2))
        except ValueError:
            pass
    if resolution is None:
        resolution = min(nii.zoom)
    if isinstance(view, str):
        view = [view]
    if ids_list is None:
        u = nii.unique()
        ids_list = [u for _ in view]
    if len(ids_list) < len(view):
        ids_list2 = []
        for i in ids_list:
            for _ in view:
                ids_list2.append(i)  # noqa: PERF401
        ids_list = ids_list2

    # TOP : ("A", "I", "R")
    nii = nii.reorient(("A", "S", "L")).rescale_((resolution, resolution, resolution), mode="constant")
    width = int(max(nii.shape[0], nii.shape[2]) * width_factor)
    window_size = (width * len(ids_list) * png_magnify, nii.shape[1] * png_magnify)
    with Xvfb():
        scene = window.Scene()
        show_m = window.ShowManager(scene=scene, size=window_size, reset_camera=False, png_magnify=png_magnify)
        show_m.initialize()
        for i, ids in enumerate(ids_list):
            x = width * i
            _plot_sub_seg(
                scene,
                nii.extract_label(ids, keep_label=True),
                x,
                0,
                smoothing,
                view[i % len(view)],
            )
        scene.projection(proj_type="parallel")
        scene.reset_camera_tight(margin_factor=1.02)
        window.record(
            scene=scene,
            size=window_size,
            out_path=output_path,
            reset_camera=False,
            magnification=scale_factor,
        )
        scene.clear()
    if not is_tmp:
        logger.on_save("Save Snapshot3D:", output_path, verbose=verbose)
    out_img = Image.open(output_path)
    if t is not None:
        t.close()
    return out_img

make_snapshot3D_parallel

make_snapshot3D_parallel(imgs: list[Image_Reference], output_paths: list[Path | str], view: VIEW | list[VIEW] = 'A', ids_list: list[Sequence[int]] | None = None, smoothing: int = 20, resolution: float = 1, cpus: int = 10, width_factor: float = 1.0, png_magnify: int = 1, scale_factor: int = 1, override: bool = True, crop: bool = True) -> None

Run :func:make_snapshot3D in parallel across multiple images.

Parameters:

Name Type Description Default
imgs list[Image_Reference]

List of segmentation image references to render.

required
output_paths list[Path | str]

Destination PNG paths, one per image in imgs.

required
view VIEW | list[VIEW]

Camera direction(s) forwarded to :func:make_snapshot3D.

'A'
ids_list list[Sequence[int]] | None

Per-view label ID lists forwarded to :func:make_snapshot3D.

None
smoothing int

VTK smoothing iterations forwarded to :func:make_snapshot3D.

20
resolution float

Isotropic voxel size (mm) for resampling.

1
cpus int

Number of worker processes in the multiprocessing pool.

10
width_factor float

Per-view width multiplier.

1.0
png_magnify int

Window pixel density multiplier.

1
scale_factor int

PNG magnification factor.

1
override bool

If False, skips images whose output file already exists.

True
crop bool

If True, crops each image to its bounding box before rendering.

True
Source code in TPTBox/mesh3D/snapshot3D.py
def make_snapshot3D_parallel(
    imgs: list[Image_Reference],
    output_paths: list[Path | str],
    view: VIEW | list[VIEW] = "A",
    ids_list: list[Sequence[int]] | None = None,
    smoothing: int = 20,
    resolution: float = 1,
    cpus: int = 10,
    width_factor: float = 1.0,
    png_magnify: int = 1,
    scale_factor: int = 1,
    override: bool = True,
    crop: bool = True,
) -> None:
    """Run :func:`make_snapshot3D` in parallel across multiple images.

    Args:
        imgs: List of segmentation image references to render.
        output_paths: Destination PNG paths, one per image in ``imgs``.
        view: Camera direction(s) forwarded to :func:`make_snapshot3D`.
        ids_list: Per-view label ID lists forwarded to :func:`make_snapshot3D`.
        smoothing: VTK smoothing iterations forwarded to :func:`make_snapshot3D`.
        resolution: Isotropic voxel size (mm) for resampling.
        cpus: Number of worker processes in the multiprocessing pool.
        width_factor: Per-view width multiplier.
        png_magnify: Window pixel density multiplier.
        scale_factor: PNG magnification factor.
        override: If False, skips images whose output file already exists.
        crop: If True, crops each image to its bounding box before rendering.
    """
    ress = []
    with Pool(cpus) as p:  # type: ignore
        for out_path, img in zip_strict(output_paths, imgs):
            if not override and Path(out_path).exists():
                continue
            res = p.apply_async(
                make_snapshot3D,
                kwds={
                    "output_path": out_path,
                    "img": img,
                    "view": view,
                    "ids_list": ids_list,
                    "smoothing": smoothing,
                    "resolution": resolution,
                    "width_factor": width_factor,
                    "png_magnify": png_magnify,
                    "crop": crop,
                    "scale_factor": scale_factor,
                },
            )
            ress.append(res)
        for res in ress:
            res.get()
        p.close()
        p.join()

Mesh

TPTBox.mesh3D.mesh

MeshOutputType

Bases: Enum

Supported mesh output file formats.

Source code in TPTBox/mesh3D/mesh.py
class MeshOutputType(Enum):
    """Supported mesh output file formats."""

    PLY = "ply"

Mesh3D

Wrapper around a pyvista PolyData mesh providing save, load, and display utilities.

Source code in TPTBox/mesh3D/mesh.py
class Mesh3D:
    """Wrapper around a pyvista PolyData mesh providing save, load, and display utilities."""

    def __init__(self, mesh: pv.PolyData) -> None:
        self.mesh = mesh

    def save(self, filepath: str | Path, mode: MeshOutputType = MeshOutputType.PLY, verbose: logging = True) -> None:
        """Save the mesh to disk in the specified format.

        Args:
            filepath: Destination file path. The appropriate extension is appended if absent.
            mode: Output format. Currently only PLY is supported.
            verbose: If True, prints a confirmation message after saving.

        Raises:
            FileNotFoundError: If the parent directory of ``filepath`` does not exist.
            NotImplementedError: If ``mode`` is not supported.
        """
        filepath = str(filepath)
        if not filepath.endswith(mode.value):
            filepath += "." + mode.value

        filepath = Path(filepath)
        if not filepath.parent.exists():
            raise FileNotFoundError(filepath.parent)

        if mode == MeshOutputType.PLY:
            try:
                self.mesh.export_obj(filepath)
            except AttributeError:
                self.mesh.save(filepath)
        else:
            raise NotImplementedError(f"save with mode {mode}")
        log.print(f"Saved mesh: {filepath}", Log_Type.SAVE, verbose=verbose)

    @classmethod
    def load(cls, filepath: str | Path) -> Mesh3D:
        """Load a mesh from a file supported by pyvista.

        Args:
            filepath: Path to the mesh file (e.g. PLY, OBJ, VTK).

        Returns:
            A new ``Mesh3D`` instance wrapping the loaded mesh.

        Raises:
            AssertionError: If ``filepath`` does not exist.
        """
        assert Path(filepath).exists(), f"loading mesh from {filepath}, filepath does not exist"
        reader = pv.get_reader(str(filepath))
        mesh = reader.read()
        return Mesh3D(mesh)

    def show(self) -> None:
        """Display the mesh interactively in a pyvista window with a black background."""
        pv.start_xvfb()
        pl = pv.Plotter()
        pl.set_background("black", top=None)
        pv.global_theme.axes.show = True
        pv.global_theme.edge_color = "white"
        pv.global_theme.interactive = True

        pl.add_mesh(self.mesh)
        pl.show()

    def save_to_html(self, file_output: str | Path) -> None:
        """Export the mesh as an interactive HTML file via pyvista.

        Args:
            file_output: Destination path for the HTML file.
        """
        pv.start_xvfb()
        pl = pv.Plotter()
        pl.set_background("black", top=None)
        pl.add_axes()
        pv.global_theme.axes.show = True
        pv.global_theme.edge_color = "white"
        pv.global_theme.interactive = True

        pl.add_mesh(self.mesh)
        pl.export_html(file_output)

save

save(filepath: str | Path, mode: MeshOutputType = MeshOutputType.PLY, verbose: logging = True) -> None

Save the mesh to disk in the specified format.

Parameters:

Name Type Description Default
filepath str | Path

Destination file path. The appropriate extension is appended if absent.

required
mode MeshOutputType

Output format. Currently only PLY is supported.

PLY
verbose logging

If True, prints a confirmation message after saving.

True

Raises:

Type Description
FileNotFoundError

If the parent directory of filepath does not exist.

NotImplementedError

If mode is not supported.

Source code in TPTBox/mesh3D/mesh.py
def save(self, filepath: str | Path, mode: MeshOutputType = MeshOutputType.PLY, verbose: logging = True) -> None:
    """Save the mesh to disk in the specified format.

    Args:
        filepath: Destination file path. The appropriate extension is appended if absent.
        mode: Output format. Currently only PLY is supported.
        verbose: If True, prints a confirmation message after saving.

    Raises:
        FileNotFoundError: If the parent directory of ``filepath`` does not exist.
        NotImplementedError: If ``mode`` is not supported.
    """
    filepath = str(filepath)
    if not filepath.endswith(mode.value):
        filepath += "." + mode.value

    filepath = Path(filepath)
    if not filepath.parent.exists():
        raise FileNotFoundError(filepath.parent)

    if mode == MeshOutputType.PLY:
        try:
            self.mesh.export_obj(filepath)
        except AttributeError:
            self.mesh.save(filepath)
    else:
        raise NotImplementedError(f"save with mode {mode}")
    log.print(f"Saved mesh: {filepath}", Log_Type.SAVE, verbose=verbose)

load classmethod

load(filepath: str | Path) -> Mesh3D

Load a mesh from a file supported by pyvista.

Parameters:

Name Type Description Default
filepath str | Path

Path to the mesh file (e.g. PLY, OBJ, VTK).

required

Returns:

Type Description
Mesh3D

A new Mesh3D instance wrapping the loaded mesh.

Raises:

Type Description
AssertionError

If filepath does not exist.

Source code in TPTBox/mesh3D/mesh.py
@classmethod
def load(cls, filepath: str | Path) -> Mesh3D:
    """Load a mesh from a file supported by pyvista.

    Args:
        filepath: Path to the mesh file (e.g. PLY, OBJ, VTK).

    Returns:
        A new ``Mesh3D`` instance wrapping the loaded mesh.

    Raises:
        AssertionError: If ``filepath`` does not exist.
    """
    assert Path(filepath).exists(), f"loading mesh from {filepath}, filepath does not exist"
    reader = pv.get_reader(str(filepath))
    mesh = reader.read()
    return Mesh3D(mesh)

show

show() -> None

Display the mesh interactively in a pyvista window with a black background.

Source code in TPTBox/mesh3D/mesh.py
def show(self) -> None:
    """Display the mesh interactively in a pyvista window with a black background."""
    pv.start_xvfb()
    pl = pv.Plotter()
    pl.set_background("black", top=None)
    pv.global_theme.axes.show = True
    pv.global_theme.edge_color = "white"
    pv.global_theme.interactive = True

    pl.add_mesh(self.mesh)
    pl.show()

save_to_html

save_to_html(file_output: str | Path) -> None

Export the mesh as an interactive HTML file via pyvista.

Parameters:

Name Type Description Default
file_output str | Path

Destination path for the HTML file.

required
Source code in TPTBox/mesh3D/mesh.py
def save_to_html(self, file_output: str | Path) -> None:
    """Export the mesh as an interactive HTML file via pyvista.

    Args:
        file_output: Destination path for the HTML file.
    """
    pv.start_xvfb()
    pl = pv.Plotter()
    pl.set_background("black", top=None)
    pl.add_axes()
    pv.global_theme.axes.show = True
    pv.global_theme.edge_color = "white"
    pv.global_theme.interactive = True

    pl.add_mesh(self.mesh)
    pl.export_html(file_output)

SegmentationMesh

Bases: Mesh3D

Mesh generated from a segmentation volume using the marching-cubes algorithm.

Source code in TPTBox/mesh3D/mesh.py
class SegmentationMesh(Mesh3D):
    """Mesh generated from a segmentation volume using the marching-cubes algorithm."""

    def __init__(self, int_arr: np.ndarray | Image_Reference) -> None:
        if not isinstance(int_arr, np.ndarray):
            seg_nii = to_nii_seg(int_arr)
            seg_nii.reorient_().rescale_()
            int_arr = seg_nii.get_array()
        assert np.min(int_arr) == 0, f"min value of image is not zero, got {np.min(int_arr)}"
        assert len(int_arr.shape) == 3, f"image does not have exactly 3 dimensions, got shape {int_arr.shape}"

        # Force dtype to uint
        if np.issubdtype(int_arr.dtype, np.floating):
            print("input is of type float, converting to int")
            int_arr.astype(np.uint16)
        # calculate bounding box cutout
        bbox_crop = np_bbox_binary(int_arr, px_dist=2)
        x1, y1, z1 = bbox_crop[0].start, bbox_crop[1].start, bbox_crop[2].start
        arr_cropped = int_arr[bbox_crop]

        vertices, faces, normals, values = marching_cubes(arr_cropped, gradient_direction="ascent", step_size=1)
        self._faces = faces
        self._normals = normals
        self._values = values
        self._x1 = x1
        self._y1 = y1
        self._z1 = z1
        # make vertices
        vertices += (x1, y1, z1)  # so it has correct relative coordinates (not world coordinates!)
        self._vertices = vertices
        vfaces = np.column_stack((np.ones(len(faces)) * 3, faces)).astype(int)
        mesh = pv.PolyData(self._vertices, vfaces)
        mesh["Normals"] = normals
        mesh["values"] = values
        self.mesh = mesh

    def get_mesh_with_offset(self, offset: tuple[float, float, float]) -> pv.PolyData:
        """Return a copy of the mesh with all vertices shifted by the given offset.

        Args:
            offset: A (x, y, z) translation vector applied to every vertex.

        Returns:
            A new pyvista PolyData mesh with shifted vertex positions.
        """
        vertices = self._vertices + offset
        vfaces = np.column_stack((np.ones(len(self._faces)) * 3, self._faces)).astype(int)

        mesh = pv.PolyData(vertices, vfaces)
        mesh["Normals"] = self._normals
        mesh["values"] = self._values
        return mesh

    @classmethod
    def from_segmentation_nii(cls, seg_nii: NII, rescale_to_iso: bool = True) -> SegmentationMesh:
        """Construct a ``SegmentationMesh`` from a NIfTI segmentation image.

        Args:
            seg_nii: A NIfTI segmentation image. Must have ``seg=True``.
            rescale_to_iso: If True, resamples the image to isotropic voxel spacing before
                extracting the surface mesh.

        Returns:
            A new ``SegmentationMesh`` built from the segmentation array.

        Raises:
            AssertionError: If ``seg_nii.seg`` is False.
        """
        assert seg_nii.seg, "NII is not a segmentation"
        seg_nii.reorient_()
        if rescale_to_iso:
            seg_nii.rescale_()

        return SegmentationMesh(seg_nii.get_seg_array())

get_mesh_with_offset

get_mesh_with_offset(offset: tuple[float, float, float]) -> pv.PolyData

Return a copy of the mesh with all vertices shifted by the given offset.

Parameters:

Name Type Description Default
offset tuple[float, float, float]

A (x, y, z) translation vector applied to every vertex.

required

Returns:

Type Description
PolyData

A new pyvista PolyData mesh with shifted vertex positions.

Source code in TPTBox/mesh3D/mesh.py
def get_mesh_with_offset(self, offset: tuple[float, float, float]) -> pv.PolyData:
    """Return a copy of the mesh with all vertices shifted by the given offset.

    Args:
        offset: A (x, y, z) translation vector applied to every vertex.

    Returns:
        A new pyvista PolyData mesh with shifted vertex positions.
    """
    vertices = self._vertices + offset
    vfaces = np.column_stack((np.ones(len(self._faces)) * 3, self._faces)).astype(int)

    mesh = pv.PolyData(vertices, vfaces)
    mesh["Normals"] = self._normals
    mesh["values"] = self._values
    return mesh

from_segmentation_nii classmethod

from_segmentation_nii(seg_nii: NII, rescale_to_iso: bool = True) -> SegmentationMesh

Construct a SegmentationMesh from a NIfTI segmentation image.

Parameters:

Name Type Description Default
seg_nii NII

A NIfTI segmentation image. Must have seg=True.

required
rescale_to_iso bool

If True, resamples the image to isotropic voxel spacing before extracting the surface mesh.

True

Returns:

Type Description
SegmentationMesh

A new SegmentationMesh built from the segmentation array.

Raises:

Type Description
AssertionError

If seg_nii.seg is False.

Source code in TPTBox/mesh3D/mesh.py
@classmethod
def from_segmentation_nii(cls, seg_nii: NII, rescale_to_iso: bool = True) -> SegmentationMesh:
    """Construct a ``SegmentationMesh`` from a NIfTI segmentation image.

    Args:
        seg_nii: A NIfTI segmentation image. Must have ``seg=True``.
        rescale_to_iso: If True, resamples the image to isotropic voxel spacing before
            extracting the surface mesh.

    Returns:
        A new ``SegmentationMesh`` built from the segmentation array.

    Raises:
        AssertionError: If ``seg_nii.seg`` is False.
    """
    assert seg_nii.seg, "NII is not a segmentation"
    seg_nii.reorient_()
    if rescale_to_iso:
        seg_nii.rescale_()

    return SegmentationMesh(seg_nii.get_seg_array())

POIMesh

Bases: Mesh3D

Mesh constructed from a set of POI (point-of-interest) coordinates rendered as spheres.

Source code in TPTBox/mesh3D/mesh.py
class POIMesh(Mesh3D):
    """Mesh constructed from a set of POI (point-of-interest) coordinates rendered as spheres."""

    def __init__(
        self,
        poi: POI,
        rescale_to_iso: bool = True,
        regions: list[int] | None = None,
        subregions: list[int] | None = None,
        size_factor: float = 5,
    ) -> None:
        poi.reorient_()
        if rescale_to_iso:
            poi.rescale_()

        if regions is None:
            regions = poi.keys_region()

        if subregions is None:
            subregions = poi.keys_subregion()

        self.poi_extracted: list[COORDINATE] = []
        self.size_factor = size_factor

        for r_id, s_id, coord in poi.items():
            if r_id in regions and s_id in subregions:
                self.poi_extracted.append(coord)

        assert len(self.poi_extracted) > 0, "no POIs present"
        n = pv.PolyData(self.poi_extracted)
        n["radius"] = np.ones(shape=len(self.poi_extracted)) * size_factor
        geom = pv.Sphere(theta_resolution=8, phi_resolution=8)
        glyphed = n.glyph(scale="radius", geom=geom, progress_bar=False, orient=False)
        self.mesh = glyphed

    def get_mesh_with_offset(self, offset: tuple[float, float, float]) -> pv.PolyData:
        """Return a copy of the glyph mesh with all POI positions shifted by the given offset.

        Args:
            offset: A (x, y, z) translation vector applied to every point-of-interest coordinate.

        Returns:
            A new pyvista PolyData glyph mesh with shifted sphere positions.
        """
        pois_shifted = [(x + offset[0], y + offset[1], z + offset[2]) for x, y, z in self.poi_extracted]
        n = pv.PolyData(pois_shifted)
        n["radius"] = np.ones(shape=len(pois_shifted)) * self.size_factor
        geom = pv.Sphere(theta_resolution=8, phi_resolution=8)
        glyphed = n.glyph(scale="radius", geom=geom, progress_bar=False, orient=False)
        return glyphed

get_mesh_with_offset

get_mesh_with_offset(offset: tuple[float, float, float]) -> pv.PolyData

Return a copy of the glyph mesh with all POI positions shifted by the given offset.

Parameters:

Name Type Description Default
offset tuple[float, float, float]

A (x, y, z) translation vector applied to every point-of-interest coordinate.

required

Returns:

Type Description
PolyData

A new pyvista PolyData glyph mesh with shifted sphere positions.

Source code in TPTBox/mesh3D/mesh.py
def get_mesh_with_offset(self, offset: tuple[float, float, float]) -> pv.PolyData:
    """Return a copy of the glyph mesh with all POI positions shifted by the given offset.

    Args:
        offset: A (x, y, z) translation vector applied to every point-of-interest coordinate.

    Returns:
        A new pyvista PolyData glyph mesh with shifted sphere positions.
    """
    pois_shifted = [(x + offset[0], y + offset[1], z + offset[2]) for x, y, z in self.poi_extracted]
    n = pv.PolyData(pois_shifted)
    n["radius"] = np.ones(shape=len(pois_shifted)) * self.size_factor
    geom = pv.Sphere(theta_resolution=8, phi_resolution=8)
    glyphed = n.glyph(scale="radius", geom=geom, progress_bar=False, orient=False)
    return glyphed

Mesh Colors

TPTBox.mesh3D.mesh_colors

RGB_Color

An RGB color stored as a NumPy integer array with helpers for normalized access.

Source code in TPTBox/mesh3D/mesh_colors.py
class RGB_Color:
    """An RGB color stored as a NumPy integer array with helpers for normalized access."""

    def __init__(self, rgb: tuple[int, int, int]):
        assert isinstance(rgb, tuple) and [isinstance(i, int) for i in rgb], "did not receive a tuple of 3 ints"
        self.rgb = np.array(rgb)

    @classmethod
    def init_separate(cls, r: int, g: int, b: int) -> RGB_Color:
        """Construct an ``RGB_Color`` from three separate channel values.

        Args:
            r: Red channel (0–255).
            g: Green channel (0–255).
            b: Blue channel (0–255).

        Returns:
            A new ``RGB_Color`` instance.
        """
        return cls((r, g, b))

    @classmethod
    def init_list(cls, rgb: list[int] | np.ndarray) -> RGB_Color:
        """Construct an ``RGB_Color`` from a list or NumPy array of three integers.

        Args:
            rgb: Sequence of three integers (R, G, B) each in the range 0–255.

        Returns:
            A new ``RGB_Color`` instance.

        Raises:
            AssertionError: If ``rgb`` does not contain exactly three elements or
                the NumPy array dtype is not int.
        """
        assert len(rgb) == 3, "rgb requires exactly three integers"
        if isinstance(rgb, np.ndarray):
            assert rgb.dtype == int, "rgb numpy array not of type int!"
        return cls(tuple(rgb))

    def __repr__(self) -> str:
        return str(self)

    def __str__(self) -> str:
        return "RGB_Color-" + str(self.rgb)

    def __call__(self, normed: bool = False) -> np.ndarray:
        """Return the RGB values as an array, optionally normalized to [0, 1].

        Args:
            normed: If True, divides each channel by 255.

        Returns:
            NumPy array of shape (3,) with either raw (0–255) or normalized (0–1) values.
        """
        if normed:
            return self.rgb / 255.0
        return self.rgb

    def __getitem__(self, item: int) -> float:
        """Return a single normalized channel value.

        Args:
            item: Channel index (0=R, 1=G, 2=B).

        Returns:
            The channel value divided by 255.
        """
        return self.rgb[item] / 255.0

init_separate classmethod

init_separate(r: int, g: int, b: int) -> RGB_Color

Construct an RGB_Color from three separate channel values.

Parameters:

Name Type Description Default
r int

Red channel (0–255).

required
g int

Green channel (0–255).

required
b int

Blue channel (0–255).

required

Returns:

Type Description
RGB_Color

A new RGB_Color instance.

Source code in TPTBox/mesh3D/mesh_colors.py
@classmethod
def init_separate(cls, r: int, g: int, b: int) -> RGB_Color:
    """Construct an ``RGB_Color`` from three separate channel values.

    Args:
        r: Red channel (0–255).
        g: Green channel (0–255).
        b: Blue channel (0–255).

    Returns:
        A new ``RGB_Color`` instance.
    """
    return cls((r, g, b))

init_list classmethod

init_list(rgb: list[int] | ndarray) -> RGB_Color

Construct an RGB_Color from a list or NumPy array of three integers.

Parameters:

Name Type Description Default
rgb list[int] | ndarray

Sequence of three integers (R, G, B) each in the range 0–255.

required

Returns:

Type Description
RGB_Color

A new RGB_Color instance.

Raises:

Type Description
AssertionError

If rgb does not contain exactly three elements or the NumPy array dtype is not int.

Source code in TPTBox/mesh3D/mesh_colors.py
@classmethod
def init_list(cls, rgb: list[int] | np.ndarray) -> RGB_Color:
    """Construct an ``RGB_Color`` from a list or NumPy array of three integers.

    Args:
        rgb: Sequence of three integers (R, G, B) each in the range 0–255.

    Returns:
        A new ``RGB_Color`` instance.

    Raises:
        AssertionError: If ``rgb`` does not contain exactly three elements or
            the NumPy array dtype is not int.
    """
    assert len(rgb) == 3, "rgb requires exactly three integers"
    if isinstance(rgb, np.ndarray):
        assert rgb.dtype == int, "rgb numpy array not of type int!"
    return cls(tuple(rgb))

Mesh_Color_List

Catalog of named RGB colors used for anatomical mesh visualization.

Contains general-purpose color constants and 201 ITK-style colors (ITK_1 through ITK_201) matching the ITK-SNAP color table convention.

Source code in TPTBox/mesh3D/mesh_colors.py
class Mesh_Color_List:
    """Catalog of named RGB colors used for anatomical mesh visualization.

    Contains general-purpose color constants and 201 ITK-style colors (``ITK_1``
    through ``ITK_201``) matching the ITK-SNAP color table convention.
    """

    # General Colors
    BEIGE = RGB_Color.init_list([255, 250, 200])
    MAROON = RGB_Color.init_list([128, 0, 0])
    YELLOW = RGB_Color.init_list([255, 255, 25])
    ORANGE = RGB_Color.init_list([245, 130, 48])
    BLUE = RGB_Color.init_list([30, 144, 255])
    BLACK = RGB_Color.init_list([0, 0, 0])
    WHITE = RGB_Color.init_list([255, 255, 255])
    GREEN = RGB_Color.init_list([50, 250, 65])
    MAGENTA = RGB_Color.init_list([240, 50, 250])
    SPRINGGREEN = RGB_Color.init_list([0, 255, 128])
    CYAN = RGB_Color.init_list([70, 240, 240])
    PINK = RGB_Color.init_list([255, 105, 180])
    BROWN = RGB_Color.init_list([160, 100, 30])
    DARKGRAY = RGB_Color.init_list([95, 93, 68])
    GRAY = RGB_Color.init_list([143, 140, 110])
    NAVY = RGB_Color.init_list([0, 0, 128])
    LIME = RGB_Color.init_list([210, 245, 60])

    ITK_1 = RGB_Color.init_list([255, 0, 0])
    ITK_2 = RGB_Color.init_list([0, 255, 0])
    ITK_3 = RGB_Color.init_list([0, 0, 255])
    ITK_4 = RGB_Color.init_list([255, 255, 0])
    ITK_5 = RGB_Color.init_list([0, 255, 255])
    ITK_6 = RGB_Color.init_list([255, 0, 255])
    ITK_7 = RGB_Color.init_list([255, 239, 213])
    ITK_8 = RGB_Color.init_list([0, 0, 205])
    ITK_9 = RGB_Color.init_list([205, 133, 63])
    ITK_10 = RGB_Color.init_list([210, 180, 140])
    ITK_11 = RGB_Color.init_list([102, 205, 170])
    ITK_12 = RGB_Color.init_list([0, 0, 128])
    ITK_13 = RGB_Color.init_list([0, 139, 139])
    ITK_14 = RGB_Color.init_list([46, 139, 87])
    ITK_15 = RGB_Color.init_list([255, 228, 225])
    ITK_16 = RGB_Color.init_list([106, 90, 205])
    ITK_17 = RGB_Color.init_list([221, 160, 221])
    ITK_18 = RGB_Color.init_list([233, 150, 122])
    ITK_19 = RGB_Color.init_list([165, 42, 42])

    ITK_20 = RGB_Color.init_list([255, 250, 250])
    ITK_21 = RGB_Color.init_list([147, 112, 219])
    ITK_22 = RGB_Color.init_list([218, 112, 214])
    ITK_23 = RGB_Color.init_list([75, 0, 130])
    ITK_24 = RGB_Color.init_list([255, 182, 193])
    ITK_25 = RGB_Color.init_list([60, 179, 113])
    ITK_26 = RGB_Color.init_list([255, 235, 205])
    ITK_27 = RGB_Color.init_list([255, 228, 196])
    ITK_28 = RGB_Color.init_list([218, 165, 32])
    ITK_29 = RGB_Color.init_list([0, 128, 128])
    ITK_30 = RGB_Color.init_list([188, 143, 143])
    ITK_31 = RGB_Color.init_list([255, 105, 180])
    ITK_32 = RGB_Color.init_list([255, 218, 185])
    ITK_33 = RGB_Color.init_list([222, 184, 135])
    ITK_34 = RGB_Color.init_list([127, 255, 0])
    ITK_35 = RGB_Color.init_list([139, 69, 19])
    ITK_36 = RGB_Color.init_list([124, 252, 0])
    ITK_37 = RGB_Color.init_list([255, 255, 224])
    ITK_38 = RGB_Color.init_list([70, 130, 180])
    ITK_39 = RGB_Color.init_list([0, 100, 0])
    ITK_40 = RGB_Color.init_list([238, 130, 238])
    ## Subregions
    ITK_41 = RGB_Color.init_list([238, 232, 170])
    ITK_42 = RGB_Color.init_list([240, 255, 240])
    ITK_43 = RGB_Color.init_list([245, 222, 179])
    ITK_44 = RGB_Color.init_list([184, 134, 11])
    ITK_45 = RGB_Color.init_list([32, 178, 170])
    ITK_46 = RGB_Color.init_list([255, 20, 147])
    ITK_47 = RGB_Color.init_list([25, 25, 112])
    ITK_48 = RGB_Color.init_list([112, 128, 144])
    ITK_49 = RGB_Color.init_list([34, 139, 34])
    ITK_50 = RGB_Color.init_list([248, 248, 255])
    ITK_51 = RGB_Color.init_list([145, 255, 150])
    ITK_52 = RGB_Color.init_list([255, 160, 122])
    ITK_53 = RGB_Color.init_list([144, 238, 144])
    ITK_54 = RGB_Color.init_list([173, 255, 47])
    ITK_55 = RGB_Color.init_list([65, 105, 225])
    ITK_56 = RGB_Color.init_list([255, 99, 71])
    ITK_57 = RGB_Color.init_list([250, 240, 230])
    ITK_58 = RGB_Color.init_list([128, 0, 0])
    ITK_59 = RGB_Color.init_list([50, 205, 50])
    ITK_60 = RGB_Color.init_list([244, 164, 96])
    ITK_61 = RGB_Color.init_list([255, 255, 240])
    ITK_62 = RGB_Color.init_list([123, 104, 238])
    ITK_63 = RGB_Color.init_list([255, 165, 0])
    ITK_64 = RGB_Color.init_list([173, 216, 230])
    ITK_65 = RGB_Color.init_list([255, 192, 203])
    ITK_66 = RGB_Color.init_list([127, 255, 212])
    ITK_67 = RGB_Color.init_list([255, 140, 0])
    ITK_68 = RGB_Color.init_list([143, 188, 143])
    ITK_69 = RGB_Color.init_list([220, 20, 60])
    ITK_70 = RGB_Color.init_list([253, 245, 230])
    ITK_71 = RGB_Color.init_list([255, 250, 240])
    ITK_72 = RGB_Color.init_list([0, 206, 209])

    ITK_73 = RGB_Color.init_list([0, 255, 127])
    ITK_74 = RGB_Color.init_list([128, 0, 128])
    ITK_75 = RGB_Color.init_list([255, 250, 205])
    ITK_76 = RGB_Color.init_list([250, 128, 114])
    ITK_77 = RGB_Color.init_list([148, 0, 211])
    ITK_78 = RGB_Color.init_list([178, 34, 34])
    ITK_79 = RGB_Color.init_list([255, 127, 80])
    ITK_80 = RGB_Color.init_list([135, 206, 235])
    ITK_81 = RGB_Color.init_list([100, 149, 237])
    ITK_82 = RGB_Color.init_list([240, 230, 140])
    ITK_83 = RGB_Color.init_list([250, 235, 215])
    ITK_84 = RGB_Color.init_list([255, 245, 238])
    ITK_85 = RGB_Color.init_list([107, 142, 35])
    ITK_86 = RGB_Color.init_list([135, 206, 250])
    ITK_87 = RGB_Color.init_list([0, 0, 139])
    ITK_88 = RGB_Color.init_list([139, 0, 139])
    ITK_89 = RGB_Color.init_list([245, 245, 220])
    ITK_90 = RGB_Color.init_list([186, 85, 211])
    ITK_91 = RGB_Color.init_list([255, 228, 181])
    ITK_92 = RGB_Color.init_list([255, 222, 173])
    ITK_93 = RGB_Color.init_list([0, 191, 255])
    ITK_94 = RGB_Color.init_list([210, 105, 30])
    ITK_95 = RGB_Color.init_list([255, 248, 220])
    ITK_96 = RGB_Color.init_list([47, 79, 79])
    ITK_97 = RGB_Color.init_list([72, 61, 139])
    ITK_98 = RGB_Color.init_list([175, 238, 238])
    ITK_99 = RGB_Color.init_list([128, 128, 0])
    ITK_100 = RGB_Color.init_list([176, 224, 230])
    ITK_101 = RGB_Color.init_list([255, 240, 245])
    ITK_102 = RGB_Color.init_list([139, 0, 0])
    ITK_103 = RGB_Color.init_list([240, 255, 255])
    ITK_104 = RGB_Color.init_list([255, 215, 0])
    ITK_105 = RGB_Color.init_list([216, 191, 216])
    ITK_106 = RGB_Color.init_list([119, 136, 153])
    ITK_107 = RGB_Color.init_list([219, 112, 147])
    ITK_108 = RGB_Color.init_list([72, 209, 204])
    ITK_109 = RGB_Color.init_list([255, 0, 255])
    ITK_110 = RGB_Color.init_list([199, 21, 133])
    ITK_111 = RGB_Color.init_list([154, 205, 50])
    ITK_112 = RGB_Color.init_list([189, 183, 107])
    ITK_113 = RGB_Color.init_list([240, 248, 255])
    ITK_114 = RGB_Color.init_list([230, 230, 250])
    ITK_115 = RGB_Color.init_list([0, 250, 154])
    ITK_116 = RGB_Color.init_list([85, 107, 47])
    ITK_117 = RGB_Color.init_list([64, 224, 208])
    ITK_118 = RGB_Color.init_list([153, 50, 204])
    ITK_119 = RGB_Color.init_list([205, 92, 92])
    ITK_120 = RGB_Color.init_list([250, 250, 210])
    ITK_121 = RGB_Color.init_list([95, 158, 160])
    ITK_122 = RGB_Color.init_list([0, 128, 0])
    ITK_123 = RGB_Color.init_list([255, 69, 0])
    ITK_124 = RGB_Color.init_list([224, 255, 255])
    ITK_125 = RGB_Color.init_list([176, 196, 222])
    ITK_126 = RGB_Color.init_list([138, 43, 226])
    ITK_127 = RGB_Color.init_list([30, 144, 255])
    ITK_128 = RGB_Color.init_list([240, 128, 128])
    ITK_129 = RGB_Color.init_list([152, 251, 152])
    ITK_130 = RGB_Color.init_list([160, 82, 45])
    ITK_131 = RGB_Color.init_list([255, 0, 0])
    ITK_132 = RGB_Color.init_list([0, 255, 0])
    ITK_133 = RGB_Color.init_list([0, 0, 255])
    ITK_134 = RGB_Color.init_list([255, 255, 0])
    ITK_135 = RGB_Color.init_list([0, 255, 255])
    ITK_136 = RGB_Color.init_list([255, 0, 255])
    ITK_137 = RGB_Color.init_list([255, 239, 213])
    ITK_138 = RGB_Color.init_list([0, 0, 205])
    ITK_139 = RGB_Color.init_list([205, 133, 63])
    ITK_140 = RGB_Color.init_list([210, 180, 140])
    ITK_141 = RGB_Color.init_list([102, 205, 170])
    ITK_142 = RGB_Color.init_list([0, 0, 128])
    ITK_143 = RGB_Color.init_list([0, 139, 139])
    ITK_144 = RGB_Color.init_list([46, 139, 87])
    ITK_145 = RGB_Color.init_list([255, 228, 225])
    ITK_146 = RGB_Color.init_list([106, 90, 205])
    ITK_147 = RGB_Color.init_list([221, 160, 221])
    ITK_148 = RGB_Color.init_list([233, 150, 122])
    ITK_149 = RGB_Color.init_list([165, 42, 42])
    ITK_150 = RGB_Color.init_list([255, 250, 250])
    ITK_151 = RGB_Color.init_list([147, 112, 219])
    ITK_152 = RGB_Color.init_list([218, 112, 214])
    ITK_153 = RGB_Color.init_list([75, 0, 130])
    ITK_154 = RGB_Color.init_list([255, 182, 193])
    ITK_155 = RGB_Color.init_list([60, 179, 113])
    ITK_156 = RGB_Color.init_list([255, 235, 205])
    ITK_157 = RGB_Color.init_list([255, 228, 196])
    ITK_158 = RGB_Color.init_list([218, 165, 32])
    ITK_159 = RGB_Color.init_list([0, 128, 128])
    ITK_160 = RGB_Color.init_list([188, 143, 143])
    ITK_161 = RGB_Color.init_list([255, 105, 180])
    ITK_162 = RGB_Color.init_list([255, 218, 185])
    ITK_163 = RGB_Color.init_list([222, 184, 135])
    ITK_164 = RGB_Color.init_list([127, 255, 0])
    ITK_165 = RGB_Color.init_list([139, 69, 19])
    ITK_166 = RGB_Color.init_list([124, 252, 0])
    ITK_167 = RGB_Color.init_list([255, 255, 224])
    ITK_168 = RGB_Color.init_list([70, 130, 180])
    ITK_169 = RGB_Color.init_list([0, 100, 0])
    ITK_170 = RGB_Color.init_list([238, 130, 238])
    ITK_171 = RGB_Color.init_list([238, 232, 170])
    ITK_172 = RGB_Color.init_list([240, 255, 240])
    ITK_173 = RGB_Color.init_list([245, 222, 179])
    ITK_174 = RGB_Color.init_list([184, 134, 11])
    ITK_175 = RGB_Color.init_list([32, 178, 170])
    ITK_176 = RGB_Color.init_list([255, 20, 147])
    ITK_177 = RGB_Color.init_list([25, 25, 112])
    ITK_178 = RGB_Color.init_list([112, 128, 144])
    ITK_179 = RGB_Color.init_list([34, 139, 34])
    ITK_180 = RGB_Color.init_list([248, 248, 255])
    ITK_181 = RGB_Color.init_list([245, 255, 250])
    ITK_182 = RGB_Color.init_list([255, 160, 122])
    ITK_183 = RGB_Color.init_list([144, 238, 144])
    ITK_184 = RGB_Color.init_list([173, 255, 47])
    ITK_185 = RGB_Color.init_list([65, 105, 225])
    ITK_186 = RGB_Color.init_list([255, 99, 71])
    ITK_187 = RGB_Color.init_list([250, 240, 230])
    ITK_188 = RGB_Color.init_list([128, 0, 0])
    ITK_189 = RGB_Color.init_list([50, 205, 50])
    ITK_190 = RGB_Color.init_list([244, 164, 96])
    ITK_191 = RGB_Color.init_list([255, 255, 240])
    ITK_192 = RGB_Color.init_list([123, 104, 238])
    ITK_193 = RGB_Color.init_list([255, 165, 0])
    ITK_194 = RGB_Color.init_list([173, 216, 230])
    ITK_195 = RGB_Color.init_list([255, 192, 203])
    ITK_196 = RGB_Color.init_list([127, 255, 212])
    ITK_197 = RGB_Color.init_list([255, 140, 0])
    ITK_198 = RGB_Color.init_list([143, 188, 143])
    ITK_199 = RGB_Color.init_list([220, 20, 60])
    ITK_200 = RGB_Color.init_list([253, 245, 230])
    ITK_201 = RGB_Color.init_list([255, 250, 240])

get_color_by_label

get_color_by_label(label: int) -> RGB_Color

Return the RGB_Color assigned to a given integer label.

Labels 1–149 have a fixed ITK-style color. Labels outside that range are wrapped modulo 50 to stay within the defined palette.

Parameters:

Name Type Description Default
label int

Integer segmentation label (must be >= 1).

required

Returns:

Type Description
RGB_Color

The RGB_Color mapped to label.

Source code in TPTBox/mesh3D/mesh_colors.py
def get_color_by_label(label: int) -> RGB_Color:
    """Return the ``RGB_Color`` assigned to a given integer label.

    Labels 1–149 have a fixed ITK-style color. Labels outside that range are
    wrapped modulo 50 to stay within the defined palette.

    Args:
        label: Integer segmentation label (must be >= 1).

    Returns:
        The ``RGB_Color`` mapped to ``label``.
    """
    if label not in _color_mapping_by_label:
        return _color_mapping_by_label[label % 50 + 1]
    return _color_mapping_by_label[label]

write_ctbl

write_ctbl(path: str | Path = 'ITK_ColorTable.ctbl') -> None

Write the ITK color table to a 3D Slicer-compatible .ctbl file.

Parameters:

Name Type Description Default
path str | Path

Destination file path. Defaults to "ITK_ColorTable.ctbl" in the current working directory.

'ITK_ColorTable.ctbl'
Source code in TPTBox/mesh3D/mesh_colors.py
def write_ctbl(path: str | Path = "ITK_ColorTable.ctbl") -> None:
    """Write the ITK color table to a 3D Slicer-compatible ``.ctbl`` file.

    Args:
        path: Destination file path. Defaults to ``"ITK_ColorTable.ctbl"`` in the
            current working directory.
    """
    with open(path, "w") as f:
        f.write("# Color table file for 3D Slicer\n")
        f.write("# Name: ITK_ColorTable\n")
        f.write("# Columns: Label Name R G B A\n\n")
        f.write("0 Background 0 0 0 0\n")

        for label, color in _color_mapping_by_label.items():
            r, g, b = color.rgb.tolist()
            f.write(f"{label} ITK_{label} {r} {g} {b} 255\n")

HTML Preview

TPTBox.mesh3D.html_preview

Preview_Settings dataclass

Configuration for visualizing a NII or POI object as a mesh.

Attributes:

Name Type Description
obj NII | POI

The image or point-of-interest object to visualize.

offset tuple[float, float, float] | None

Optional (PIR) spatial offset for rendering.

opacity float

Mesh opacity value between 0 and 1.

color Literal['auto'] | str | None

Desired mesh color. Defaults to "auto".

binary bool

Whether to render the object as a binary segmentation.

Source code in TPTBox/mesh3D/html_preview.py
@dataclass
class Preview_Settings:
    """Configuration for visualizing a `NII` or `POI` object as a mesh.

    Attributes:
        obj (NII | POI): The image or point-of-interest object to visualize.
        offset (tuple[float, float, float] | None): Optional (PIR) spatial offset for rendering.
        opacity (float): Mesh opacity value between 0 and 1.
        color (Literal["auto"] | str | None): Desired mesh color. Defaults to "auto".
        binary (bool): Whether to render the object as a binary segmentation.
    """

    obj: NII | POI
    offset: tuple[float, float, float] | None = None  # PIR
    opacity: float = 1.0
    color: Literal["auto"] | str | None = "auto"  # noqa: PYI051
    binary = False

    def _get_mesh(
        self,
        rescale_to_iso,
        poi_size,
        default_color_nii="bisque",
        default_poi_nii="red",
    ):
        """Generates one or more meshes from the underlying image or POI.

        Args:
            rescale_to_iso (bool): Whether to rescale to isotropic spacing.
            poi_size (float): Size factor for rendering POI objects.
            default_color_nii (str): Default color for NII objects.
            default_poi_nii (str): Default color for POI objects.

        Yields:
            Tuple[Mesh, str]: A tuple of the mesh and its associated color.
        """
        img = self.obj
        if (self.color is None or self.color == "auto") and not isinstance(img, NII):
            self.color = default_poi_nii
        if self.binary or (self.color is not None and self.color != "auto"):
            if isinstance(img, NII):
                mesh = SegmentationMesh.from_segmentation_nii(img, rescale_to_iso=rescale_to_iso)
                is_poi = False
            elif isinstance(img, POI):
                mesh = POIMesh(img, rescale_to_iso=False, regions=None, subregions=None, size_factor=poi_size)
                is_poi = True
            else:
                raise NotImplementedError(f"{img.__class__} is not supported")
            if self.offset is not None:
                mesh = mesh.get_mesh_with_offset(self.offset)
            color = self.color
            if color is None or color == "auto":
                color = default_poi_nii if is_poi else default_color_nii
            yield mesh, color
        elif isinstance(img, NII):
            for u in img.unique():
                color = get_color_by_label(u)
                mesh = SegmentationMesh.from_segmentation_nii(img.extract_label(u), rescale_to_iso=rescale_to_iso)
                if self.offset is not None:
                    mesh = mesh.get_mesh_with_offset(self.offset)
                yield mesh, color
        else:
            raise NotImplementedError("auto poi color")

make_html_preview

make_html_preview(images: list[NII | POI | Preview_Settings], html_out: str | Path | None, background='black', rescale_to_iso=False, poi_size=1.7, logger=l, show=False, default_color_nii='bisque', default_poi_nii='red', ref_spacing: Has_Grid | None = None, auto_rescale_to_ref=False) -> None

Render NII or POI objects as meshes in an interactive 3D HTML viewer.

Parameters:

Name Type Description Default
images list[NII | POI | Preview_Settings]

List of images or wrapped settings to visualize.

required
html_out str | Path | None

Output file path for HTML export. Must end in .html.

required
background str

Background color of the 3D viewer.

'black'
rescale_to_iso bool

Whether to rescale NII images to isotropic voxel spacing.

False
poi_size float

Size factor for point-of-interest rendering.

1.7
logger Print_Logger

Logger for output messages.

l
show bool

If True, shows the viewer after rendering.

False
default_color_nii str

Default color for NII-based visualizations.

'bisque'
default_poi_nii str

Default color for POI-based visualizations.

'red'
ref_spacing Has_Grid | None

Optional reference object to resample all images to a common spacing.

None
auto_rescale_to_ref bool

Whether to resample all objects to the reference spacing automatically.

False

Raises:

Type Description
AssertionError

If html_out is invalid or neither html_out nor show is provided.

Source code in TPTBox/mesh3D/html_preview.py
def make_html_preview(
    images: list[NII | POI | Preview_Settings],
    html_out: str | Path | None,
    background="black",
    rescale_to_iso=False,
    poi_size=1.7,
    logger=l,
    show=False,
    default_color_nii="bisque",
    default_poi_nii="red",
    ref_spacing: Has_Grid | None = None,
    auto_rescale_to_ref=False,
) -> None:
    """Render NII or POI objects as meshes in an interactive 3D HTML viewer.

    Args:
        images (list[NII | POI | Preview_Settings]): List of images or wrapped settings to visualize.
        html_out (str | Path | None): Output file path for HTML export. Must end in `.html`.
        background (str): Background color of the 3D viewer.
        rescale_to_iso (bool): Whether to rescale NII images to isotropic voxel spacing.
        poi_size (float): Size factor for point-of-interest rendering.
        logger (Print_Logger): Logger for output messages.
        show (bool): If True, shows the viewer after rendering.
        default_color_nii (str): Default color for NII-based visualizations.
        default_poi_nii (str): Default color for POI-based visualizations.
        ref_spacing (Has_Grid | None): Optional reference object to resample all images to a common spacing.
        auto_rescale_to_ref (bool): Whether to resample all objects to the reference spacing automatically.

    Raises:
        AssertionError: If `html_out` is invalid or neither `html_out` nor `show` is provided.
    """
    assert (html_out is None) or str(html_out).endswith(".html"), f"not a valid file ending {html_out}; expected .html"
    assert html_out is not None or show, "show must be True or html_out must be set"
    pl: pv.Plotter = pv.Plotter()  # type: ignore
    pl.set_background(background, top=None)  # type: ignore
    pl.add_axes()  # type: ignore

    images_ = [Preview_Settings(obj) if not isinstance(obj, Preview_Settings) else obj for obj in images]
    if ref_spacing is not None:
        auto_rescale_to_ref = True
    if auto_rescale_to_ref and ref_spacing is None:
        for a in images_:
            if isinstance(a, Has_Grid):
                ref_spacing = a
            break
    if auto_rescale_to_ref:
        assert ref_spacing is not None

        def resample(obj: Preview_Settings) -> Preview_Settings:
            obj.obj = obj.obj.resample_from_to(ref_spacing)
            return obj

        images_ = [resample(obj) for obj in images_]

    for setting in images_:
        for m, color in setting._get_mesh(
            poi_size=poi_size, rescale_to_iso=rescale_to_iso, default_color_nii=default_color_nii, default_poi_nii=default_poi_nii
        ):
            _add_mesh(pl, m, opacity=setting.opacity, color=color)

    if html_out is not None:
        pl.export_html(html_out)
        logger.print(f"Saved scene into {html_out}", Log_Type.SAVE)
    if show:
        pl.show()