Skip to content

roi_plate

tit.figures.roi_plate

The ROI scene: one small *.tetravox.json that points at the files a job already has.

Why this exists

A number about an ROI is only as good as the ROI. A mask that landed in the wrong hemisphere, kept an island 40 mm away, or came back empty from a transform produces a self-consistent result about the wrong voxels — the optimisation converges, the focality ratio is finite, the analyzer's table is full. So every optimizer and every analyzer run leaves behind something a person can open and look at, written at the start of the run, for every ROI, in every space.

What it leaves behind is one file per target::

roi.tetravox.json         the scene: the subject's own T1 plus the ROI layer

(An analysis writes no ROI-only scene: its one scene.tetravox.json shows the field masked to the ROI over the anatomy -- :mod:tit.analyzer.scene, built on the same helpers.)

A scene is the format Tetravox's own File ▸ Save Scene writes and Open in Tetravox reads, so the artefact is the viewer's own document, not a picture of one. It is a few kilobytes and it references files that already exist — the subject's m2m/T1.nii.gz, the atlas the target names, the hemisphere's central surface and its .annot. Nothing is rasterised, resampled or duplicated to make it, with exactly one exception: an MNI target is not the ROI that runs, so the transformed mask is written once, as a compressed uint8 roi_mask.nii.gz on the atlas's voxel grid (about 100 KB), and the scene points at that.

Every dataset path is written relative to the scene file whenever it is inside the project, which is what makes one file work both in the container that wrote it and on the host that opens it: neither has to rewrite a path.

Framing

The user's requirement is that the ROI be centred in the point of view and fill it. :func:plan_framing turns a mask into a :class:FramingPlan that says where the cursor goes and how far the view reaches, by these rules (:data:RULES names each one; the chosen one is recorded in the scene's meta):

single One connected region. Cursor at its centroid, snapped to the nearest in-mask voxel when the centroid falls outside the mask (a C-shaped hippocampus's centroid sits in the ventricle). Zoom so the region's bounding box fills :data:FILL_FRACTION of the panel. union Several regions whose union spans no more than :data:SPAN_LIMIT_MM (bilateral thalami, a two-label union). Cursor at the centroid of the union, snapped to the nearest in-mask voxel of the largest region; zoom to the union's bounding box. per-region Several regions whose union spans more than :data:SPAN_LIMIT_MM. A scene has one cursor, so it is framed on the largest region; the others are listed in meta.regions with their own cursors. sphere A spherical ROI. Cursor at the sphere's centre (not the mask centroid — the centre is what the user typed), radius drives the zoom. empty Nothing in the mask. No scene, one terminal line, and that is the failure the user most needs to see.

Two rules, each with the failure it prevents:

  • A scene never fails a job. Everything here is wrapped: the T1 may be unreadable, the output directory read-only. None of that is a reason to refuse to run an optimisation. A failure is one log line.
  • It describes the ROI that will actually be used, never a second computation of it.

The optional picture

There is no renderer in this module and no matplotlib. On a desktop with Tetravox installed, desktop/src/main/roiPlates.ts runs it once per scene when the job finishes and writes <scene>.png beside it. No Tetravox, no PNG — the scene is still there and Open in Tetravox still works.

Region dataclass

Region(name: str, voxels: int, centroid_ras: list[float], cursor_ras: list[float], bbox_min_ras: list[float], bbox_max_ras: list[float], color: str = PALETTE[0], value: int = 1, world: object = None)

One connected component or one label of the ROI, in world millimetres.

Row dataclass

Row(label: str, cursor_ras: list[float], width_mm: float, regions: list[Region])

One A/B/C row of the plate: a cursor, a zoom and the regions drawn in it.

FramingPlan dataclass

FramingPlan(rule: str, regions: list[Region], rows: list[Row] = list(), omitted_regions: list[str] = list(), reason: str = '', island_voxels: int = 0, region_map: object | None = None)

Where the cursor goes and how far the view reaches, and why.

plan_framing

plan_framing(mask, affine, *, names=None, spheres=None, span_limit_mm: float = SPAN_LIMIT_MM) -> FramingPlan

Decide the cursor and the zoom for mask (a 3-D array in a RAS grid).

Parameters:

Name Type Description Default
mask

integer array. Non-zero is in the ROI. Distinct positive values are treated as distinct regions; a binary mask is split into connected components instead.

required
affine

the array's voxel-to-world (RAS, millimetres) affine.

required
names

optional {value: name} for a labelled mask, or a list of names in descending-size order.

None
spheres

optional [(centre_ras, radius_mm), ...] — a spherical ROI's cursor is its centre and its radius drives the zoom, because the centre is what the user typed and the mask is only its rasterisation.

None
span_limit_mm float

above this union span the plate gets one row per region.

SPAN_LIMIT_MM

Returns:

Name Type Description
A FramingPlan

class:FramingPlan; rule == "empty" when nothing is in the mask.

Source code in tit/figures/roi_plate.py
def plan_framing(
    mask,
    affine,
    *,
    names=None,
    spheres=None,
    span_limit_mm: float = SPAN_LIMIT_MM,
) -> FramingPlan:
    """Decide the cursor and the zoom for *mask* (a 3-D array in a RAS grid).

    Args:
        mask: integer array. Non-zero is in the ROI. Distinct positive values are
            treated as distinct regions; a binary mask is split into connected
            components instead.
        affine: the array's voxel-to-world (RAS, millimetres) affine.
        names: optional ``{value: name}`` for a labelled mask, or a list of names
            in descending-size order.
        spheres: optional ``[(centre_ras, radius_mm), ...]`` — a spherical ROI's
            cursor is its **centre** and its radius drives the zoom, because the
            centre is what the user typed and the mask is only its rasterisation.
        span_limit_mm: above this union span the plate gets one row per region.

    Returns:
        A :class:`FramingPlan`; ``rule == "empty"`` when nothing is in the mask.
    """
    import numpy as np

    array = np.asarray(mask)
    if array.ndim != 3:
        array = np.squeeze(array)
    values = np.rint(array).astype(np.int64)
    if not values.any():
        return FramingPlan(
            rule="empty", regions=[], reason="the mask has no non-zero voxels"
        )

    import nibabel as nib

    region_map, ids, kind = _region_map(values)
    half = np.abs(np.asarray(affine)[:3, :3]).sum(axis=0) / 2.0
    regions: list[Region] = []
    for value in ids:
        voxels = np.argwhere(region_map == value)
        if not len(voxels):
            continue
        world = nib.affines.apply_affine(affine, voxels)
        regions.append(_region(f"region {value}", world, half, PALETTE[0], value=value))
    regions.sort(key=lambda r: r.voxels, reverse=True)

    islands = 0
    if kind == "components" and len(regions) > 1:
        floor = max(MIN_REGION_VOXELS, MIN_REGION_FRACTION * regions[0].voxels)
        kept = [r for r in regions if r.voxels >= floor] or regions[:1]
        for region in regions[len(kept) :]:
            # Folded into the largest region so they still draw, in its colour.
            region_map[region_map == region.value] = kept[0].value
            islands += region.voxels
        regions = kept

    for index, region in enumerate(regions):
        region.color = PALETTE[index % len(PALETTE)]
        # A name dictionary is keyed by *label value*, which only a labelled mask
        # has; components are numbered, so only a positional list applies to them.
        lookup = names if kind == "labels" or not isinstance(names, dict) else None
        region.name = _name_for(lookup, index, region.value, f"region {region.value}")

    # A sphere's centre beats its rasterised centroid, and its radius beats its
    # bounding box: both are what the user typed, and a rasterised sphere's
    # bounding box is a voxel or two bigger on every side.
    if spheres:
        for centre, radius in spheres:
            centre = [float(v) for v in centre]
            target = min(
                regions,
                key=lambda r: sum((a - b) ** 2 for a, b in zip(r.centroid_ras, centre)),
            )
            target.cursor_ras = centre
            target.bbox_min_ras = [v - float(radius) for v in centre]
            target.bbox_max_ras = [v + float(radius) for v in centre]

    return _rows_for(regions, spheres, span_limit_mm, islands, region_map)

plan_spheres

plan_spheres(spheres, *, names=None) -> FramingPlan

The framing for a spherical target, from the centres and radii typed.

A sphere has no file and needs none: its centre is the cursor and its radius is the zoom, both exactly as the user typed them, which is why this is the one target whose framing is not derived from voxels at all. The scene it produces is the subject's T1 with the crosshair on the centre -- Tetravox 0.5.2's ViewSpec has no sphere or marker primitive to draw the extent with (packages/engine/src/scene/types.ts: sphere appears only inside a mesh IsolateSpec), so the crosshair and the scale bar are what say where and how big.

Source code in tit/figures/roi_plate.py
def plan_spheres(spheres, *, names=None) -> FramingPlan:
    """The framing for a **spherical** target, from the centres and radii typed.

    A sphere has no file and needs none: its centre is the cursor and its radius
    is the zoom, both exactly as the user typed them, which is why this is the
    one target whose framing is not derived from voxels at all.  The scene it
    produces is the subject's T1 with the crosshair on the centre -- Tetravox
    0.5.2's ``ViewSpec`` has no sphere or marker primitive to draw the extent
    with (``packages/engine/src/scene/types.ts``: ``sphere`` appears only inside
    a mesh ``IsolateSpec``), so the crosshair and the scale bar are what say
    where and how big.
    """
    regions = [
        _region(
            _name_for(names, index, index + 1, f"sphere {index + 1}"),
            [[float(v) for v in centre]],
            (float(radius), float(radius), float(radius)),
            PALETTE[index % len(PALETTE)],
            value=index + 1,
            count=1,
        )
        for index, (centre, radius) in enumerate(spheres)
    ]
    if not regions:
        return FramingPlan(rule="empty", regions=[], reason="no sphere was given")
    plan = _rows_for(regions, list(spheres), SPAN_LIMIT_MM, 0, None)
    plan.rule = "sphere"
    return plan

plan_surface

plan_surface(groups, *, names=None, span_limit_mm: float = SPAN_LIMIT_MM) -> FramingPlan

The same framing for a cortical target, from its surface vertices.

groups is one (N, 3) array of world-millimetre vertices per region -- the labelled vertices of the hemisphere's central surface. A cortical target is a set of vertices, not voxels, and this is what lets the scene reference the .annot itself instead of a rasterisation of it: nothing is written to get a cursor.

Half a millimetre of half-extent per vertex, because a vertex is a point and a zero-span bounding box would make the zoom infinite.

Source code in tit/figures/roi_plate.py
def plan_surface(
    groups, *, names=None, span_limit_mm: float = SPAN_LIMIT_MM
) -> FramingPlan:
    """The same framing for a **cortical** target, from its surface vertices.

    *groups* is one ``(N, 3)`` array of world-millimetre vertices per region --
    the labelled vertices of the hemisphere's central surface.  A cortical target
    is a set of vertices, not voxels, and this is what lets the scene reference
    the ``.annot`` itself instead of a rasterisation of it: nothing is written to
    get a cursor.

    Half a millimetre of half-extent per vertex, because a vertex is a point and
    a zero-span bounding box would make the zoom infinite.
    """
    regions: list[Region] = []
    for index, points in enumerate(groups):
        if not len(points):
            continue
        name = _name_for(names, index, index + 1, f"region {index + 1}")
        regions.append(
            _region(name, points, (0.5, 0.5, 0.5), PALETTE[0], value=index + 1)
        )
    if not regions:
        return FramingPlan(
            rule="empty", regions=[], reason="the target covers no surface vertex"
        )
    regions.sort(key=lambda r: r.voxels, reverse=True)
    for index, region in enumerate(regions):
        region.color = PALETTE[index % len(PALETTE)]
    return _rows_for(regions, None, span_limit_mm, islands=0, region_map=None)

scene_path

scene_path(target: str, scene_dir: str) -> str

target as the scene addresses it: relative to the scene when it can be.

A relative path is the one addressing that is correct in the container that wrote the scene and on the host that opens it, because both see the same project tree under different roots. Anything outside the project (a bundled MNI atlas) keeps its absolute path -- it is not part of what gets re-rooted, and a ../../../.. chain out of the project would be wrong on both.

Source code in tit/figures/roi_plate.py
def scene_path(target: str, scene_dir: str) -> str:
    """*target* as the scene addresses it: relative to the scene when it can be.

    A relative path is the one addressing that is correct in the container that
    wrote the scene **and** on the host that opens it, because both see the same
    project tree under different roots.  Anything outside the project (a bundled
    MNI atlas) keeps its absolute path -- it is not part of what gets re-rooted,
    and a `../../../..` chain out of the project would be wrong on both.
    """
    try:
        from tit.paths import get_path_manager

        project = get_path_manager().project_dir
    except Exception:  # noqa: BLE001 - outside a project there is no re-rooting
        project = None
    resolved = os.path.realpath(str(target))
    if project:
        root = os.path.realpath(str(project))
        if resolved == root or resolved.startswith(root.rstrip(os.sep) + os.sep):
            return os.path.relpath(resolved, os.path.realpath(str(scene_dir)))
    return resolved

build_scene

build_scene(*, scene_dir: str, anatomy: str, roi_layers: list[dict], plan: FramingPlan, meta: dict) -> dict

The ViewSpec for one target. Pure but for reading volume headers.

Parameters:

Name Type Description Default
scene_dir str

the directory the scene file goes in (paths are relative to it).

required
anatomy str

the subject's own T1.nii.gz.

required
roi_layers list[dict]

one entry per ROI source, each {"kind": "volume"|"surface", "path": ..., "labels": {value: colour}, "annot": <path, surface only>}.

required
plan FramingPlan

the framing -- its first row gives the cursor and the zoom.

required
meta dict

what goes in the scene's meta block (the numbers the terminal line prints, so nothing is lost by dropping the JSON sidecar).

required
Source code in tit/figures/roi_plate.py
def build_scene(
    *,
    scene_dir: str,
    anatomy: str,
    roi_layers: list[dict],
    plan: FramingPlan,
    meta: dict,
) -> dict:
    """The ViewSpec for one target.  Pure but for reading volume headers.

    Args:
        scene_dir: the directory the scene file goes in (paths are relative to it).
        anatomy: the subject's own ``T1.nii.gz``.
        roi_layers: one entry per ROI source, each
            ``{"kind": "volume"|"surface", "path": ..., "labels": {value: colour},
            "annot": <path, surface only>}``.
        plan: the framing -- its first row gives the cursor and the zoom.
        meta: what goes in the scene's ``meta`` block (the numbers the terminal
            line prints, so nothing is lost by dropping the JSON sidecar).
    """
    datasets: list[dict] = []
    layers: list[dict] = [anatomy_layer(anatomy)]
    datasets.append(_dataset(0, anatomy, scene_dir, "volume"))

    # A label volume is listed twice and styled twice: a VolumeLayer has one
    # opacity and one `labelMode`, so a 40 % fill under an opaque outline is two
    # layers over one dataset.
    fills: list[dict] = []
    outlines: list[dict] = []
    for entry in roi_layers:
        index = len(datasets)
        path = entry["path"]
        colors = {str(k): _rgba(v) for k, v in entry["labels"].items()}
        visible = sorted(int(k) for k in entry["labels"])
        if entry["kind"] == "surface":
            annot = entry["annot"]
            datasets.append(
                _dataset(
                    index,
                    path,
                    scene_dir,
                    "surface",
                    sidecars={
                        # Relative to the **surface's** own directory: the one path
                        # in a scene that is never re-rooted (Tetravox section 4.6).
                        "fields": [
                            {"path": os.path.relpath(annot, os.path.dirname(path))}
                        ]
                    },
                )
            )
            fills.append(
                _base_layer(
                    index,
                    f"ds{index}",
                    os.path.basename(path),
                    kind="surface",
                    colorMode="annotation",
                    solidColor=_rgba(PALETTE[0]),
                    colormap="viridis",
                    scale={"kind": "linear", "lo": 0.0, "hi": 1.0},
                    threshold=dict(_DEFAULT_THRESHOLD),
                    annotation={
                        # The node field the worker creates is named after the file
                        # it attached, extension and all.
                        "name": os.path.basename(annot),
                        "mode": "both",
                        "outlineWidthPx": 2.0,
                        "visibleLabels": visible,
                    },
                    flatShading=False,
                    faceMode="cull",
                    edges=False,
                    edgeColor=[0.0, 0.0, 0.0, 1.0],
                    edgeWidthPx=1.0,
                    clip={"planes": []},
                    contoursIn2D=True,
                    contourWidthPx=2.0,
                    contourColor=_rgba(list(entry["labels"].values())[0]),
                )
            )
            continue
        datasets.append(_dataset(index, path, scene_dir, "volume"))
        common = dict(
            interpolation="nearest",
            # The plan's own palette, not the file's LUT: a binary mask's value 1
            # is blue in every LUT, and a two-region target must show two colours
            # whatever the atlas called them.
            labelColors=colors,
            visibleLabels=visible,
            showIn3D=False,
        )
        fills.append(
            _volume_layer(
                index,
                f"ds{index}",
                os.path.basename(path),
                opacity=0.4,
                labelMode="fill",
                **common,
            )
        )
        outlines.append(
            _volume_layer(
                len(roi_layers) + index,
                f"ds{index}",
                os.path.basename(path),
                labelMode="outline",
                outlineWidthPx=2,
                **common,
            )
        )
    layers.extend(fills)
    layers.extend(outlines)

    row = plan.rows[0]
    return assemble_scene(
        datasets=datasets,
        layers=layers,
        cursor=[float(v) for v in row.cursor_ras],
        mm_per_px=float(row.mm_per_px),
        bounds=_volume_bounds(anatomy),
        meta=meta,
    )

anatomy_layer

anatomy_layer(anatomy: str, index: int = 0, dataset_id: str = 'ds0') -> dict

The grey T1 under everything, windowed so the brain is not washed out.

Source code in tit/figures/roi_plate.py
def anatomy_layer(anatomy: str, index: int = 0, dataset_id: str = "ds0") -> dict:
    """The grey T1 under everything, windowed so the brain is not washed out."""
    return _volume_layer(
        index,
        dataset_id,
        os.path.basename(anatomy),
        scale={"kind": "linear", "lo": 0.0, "hi": _upper_window(anatomy)},
    )

assemble_scene

assemble_scene(*, datasets: list[dict], layers: list[dict], cursor: list[float], mm_per_px: float, bounds, meta: dict, layout: str = '2x2', transparency: str = 'twoPhase') -> dict

A version-2 ViewSpec around layers: the panes framed on cursor.

Shared by the ROI scene and the analysis scene (:mod:tit.analyzer.scene), so both frame the same way. bounds is the (lo, hi) world box the slice cameras are centred against and the 3D camera is fitted to.

Source code in tit/figures/roi_plate.py
def assemble_scene(
    *,
    datasets: list[dict],
    layers: list[dict],
    cursor: list[float],
    mm_per_px: float,
    bounds,
    meta: dict,
    layout: str = "2x2",
    transparency: str = "twoPhase",
) -> dict:
    """A version-2 ViewSpec around *layers*: the panes framed on *cursor*.

    Shared by the ROI scene and the analysis scene (:mod:`tit.analyzer.scene`),
    so both frame the same way.  *bounds* is the ``(lo, hi)`` world box the
    slice cameras are centred against and the 3D camera is fitted to.
    """
    # Re-id the layers in the order they were finally stacked, so a reader of the
    # file sees layer0 at the bottom and nothing has to be sorted to draw it.
    for position, layer in enumerate(layers):
        layer["id"] = f"layer{position}"

    lo, hi = bounds
    centre = [(lo[i] + hi[i]) / 2.0 for i in range(3)]
    offset = [cursor[i] - centre[i] for i in range(3)]
    radius = max(1.0, 0.5 * math.dist(lo, hi))

    slices = []
    for slice_id, normal, up in _SLICE_AXES:
        right = _cross(up, normal)
        slices.append(
            {
                "id": slice_id,
                "mode": slice_id,
                "normal": list(normal),
                "up": list(up),
                # The camera's `center` is in-plane and relative to the **scene
                # bounds centre**, not to the cursor (Tetravox section 4.5: a pane
                # whose map followed the cursor would slide whenever the cursor
                # moved).  So "the ROI is centred" is this offset, computed here.
                "camera": {
                    "center": [
                        round(sum(offset[i] * right[i] for i in range(3)), 4),
                        round(sum(offset[i] * up[i] for i in range(3)), 4),
                    ],
                    "mmPerPx": round(mm_per_px, 6),
                },
            }
        )

    return {
        "version": 2,
        "datasets": datasets,
        "layers": layers,
        "activeLayerId": layers[1]["id"] if len(layers) > 1 else layers[0]["id"],
        "slices": slices,
        "view3d": {
            "id": "view3d",
            "camera": {
                "target": cursor,
                "distance": radius / math.sin(math.radians(35.0) * 0.5),
                "rotation": [0.3317, -0.6158, -0.6307, 0.3363],
                "fovYDeg": 35.0,
                "orthographic": False,
                "near": max(1.0, radius / 1000.0),
                "far": radius * 8.0,
            },
            "showSlicePlanes": False,
        },
        "layout": {"kind": layout, "cells": LAYOUT_CELLS[layout]},
        "cursor": cursor,
        "radiological": False,
        "background": list(_BACKGROUND),
        "lighting": dict(_LIGHTING),
        "annotations": dict(_ANNOTATIONS),
        "transparency": {"mode": transparency},
        # Not engine state, and deliberately the only sidecar: every number the
        # retired `roi_plate.json` carried lives here, in the file the user opens.
        "meta": meta,
    }

write_roi_scene

write_roi_scene(*, out_dir: str, anatomy: str, roi_layers: list[dict], plan: FramingPlan, meta: dict, title: str = '') -> dict | None

Write one ROI scene and announce it. Never raises.

Returns the meta block as written, or None when the scene was switched off or could not be written -- a job must not fail over an artefact a person looks at.

Source code in tit/figures/roi_plate.py
def write_roi_scene(
    *,
    out_dir: str,
    anatomy: str,
    roi_layers: list[dict],
    plan: FramingPlan,
    meta: dict,
    title: str = "",
) -> dict | None:
    """Write one ROI scene and announce it.  Never raises.

    Returns the ``meta`` block as written, or ``None`` when the scene was
    switched off or could not be written -- a job must not fail over an artefact
    a person looks at.
    """
    if not enabled():
        return None
    name = SCENE_NAME
    destination = Path(out_dir)
    try:
        destination.mkdir(parents=True, exist_ok=True)
        if plan.empty:
            print(
                f"ROI {title}: the target is EMPTY -- {plan.reason}; no scene written",
                flush=True,
            )
            logger.warning("ROI %s is empty: %s", title, plan.reason)
            return None
        scene = build_scene(
            scene_dir=str(destination),
            anatomy=anatomy,
            roi_layers=roi_layers,
            plan=plan,
            meta=meta,
        )
        path = destination / name
        path.write_text(json.dumps(scene, indent=1) + "\n", encoding="utf-8")
        _announce(path, "json", f"ROI scene — {title}" if title else "ROI scene")
        x, y, z = (round(v, 2) for v in plan.cursor_ras)
        overlap = meta.get("gm_overlap")
        overlap_text = (
            "GM overlap unknown"
            if overlap is None
            else f"GM overlap {overlap * 100:.0f} %"
        )
        print(
            f"ROI {title}: {meta.get('voxels', 0)} {meta.get('unit', 'voxels')}, "
            f"cursor ({x:g}, {y:g}, {z:g}) mm [{plan.rule}], {overlap_text} "
            f"-- see {name}",
            flush=True,
        )
        return meta
    except Exception as exc:  # noqa: BLE001 - a scene never fails a job
        logger.warning("ROI scene could not be written: %s", exc)
        return None

write_mask_nii_gz

write_mask_nii_gz(image, destination: Path) -> None

Save a NIfTI as gzip, without nibabel's own gzip writer.

nibabel writes a .nii.gz through a file object it seeks backwards in, which fails on a bind-mounted project inside the container. Writing the plain .nii to a scratch file and compressing it with the standard library is the one thing that works on every mount this ships on.

Source code in tit/figures/roi_plate.py
def write_mask_nii_gz(image, destination: Path) -> None:
    """Save a NIfTI as gzip, without nibabel's own gzip writer.

    nibabel writes a `.nii.gz` through a file object it seeks backwards in, which
    fails on a bind-mounted project inside the container.  Writing the plain
    `.nii` to a scratch file and compressing it with the standard library is the
    one thing that works on every mount this ships on.
    """
    import tempfile

    import nibabel as nib

    destination.parent.mkdir(parents=True, exist_ok=True)
    with tempfile.TemporaryDirectory(prefix="roi-mask-") as scratch:
        plain = Path(scratch) / "mask.nii"
        nib.save(image, str(plain))
        with open(plain, "rb") as source, gzip.open(str(destination), "wb") as out:
            shutil.copyfileobj(source, out)