Skip to content

figures

tit.figures

Artefacts TI-Toolbox leaves behind without being asked.

Today that is one thing: the ROI scene (:mod:tit.figures.roi_plate) — one small roi.tetravox.json per target, written by every optimizer run, so a person can open the voxels the search is about before trusting a number about them. An analysis instead writes one scene.tetravox.json of its field masked to the ROI (:mod:tit.analyzer.scene), built on the same helpers. A scene references files that already exist and draws nothing itself; the optional PNG is made on the host by Tetravox (desktop/src/main/roiPlates.ts).

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_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)

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