Skip to content

viewspec

tit.viewspec

Declarative view specifications for the Freeview/Gmsh launchers.

build_view(kind, ...) reproduces the layer-building logic of the former PyQt NIfTI viewer tab (single-subject, group and overlay layer stacks) as a pure function returning a JSON-able ViewSpec (contracts/openapi.yaml #/components/schemas/ViewSpec) instead of driving Qt widgets and a subprocess directly. to_freeview_args reproduces the argv grammar of launch_freeview_with_files (nifti_viewer_tab.py:1124-1140).

Six audit bugs from TODO.md §2.6 are fixed here (each pinned by a test in tests/test_viewspec.py):

  1. HF glob never matching. The Qt tab globs high_Frequency/niftis/*_scalar_magnE.nii.gz for the high-frequency envelope overlay, but every real file is named ..._scalar_subject_magnE.nii.gz or ..._scalar_MNI_MNI_magnE.nii.gz (verified against sub-ernie in Dataset 000) — the un-suffixed pattern matches nothing, ever. Fixed by globbing *_scalar_*magnE.nii.gz and filtering by the same _MNI substring rule used for TI_max niftis.
  2. labeling_LUT.txt ignored. The Qt tab sends the subject's labeling.nii.gz atlas overlay with colormap=lut and no LUT file at all, so regions render with Freeview's arbitrary default palette instead of the SimNIBS tissue colours. Fixed via VoxelAtlasManager.find_labeling_lut().
  3. *_LUT.txt not found in group/MNI mode. The bundled MNI atlases' sidecars are named <stem>_LUT.txt (e.g. CIT168_labeling_MNI152NLin2009cAsym_LUT.txt, resources/atlas/), but the Qt tab's candidate list only tries <stem>.txt / <stem>_labels.txt / a hyphen-split guess — never _LUT.txt, and never the massp2021_labels.txt special case for the MASSP atlas (whose sidecar does not share its stem at all). Fixed by trying _LUT.txt first, matching roi_picker._find_volume_lut.
  4. MNI paths hard-coded to the container. MNI_ATLAS_DIR is the absolute container path /ti-toolbox/resources/atlas; outside the container (host-run tests, --dump-openapi) it silently finds nothing. Fixed with the same repo-relative fallback roi_picker._mni_atlas_dir() uses.
  5. Absolute thresholds dropped when percentile mode is off. launch_freeview_with_files only emits heatscale=... inside the if spec.get("percentile") branch, so a user-set absolute min/max threshold is silently ignored whenever percentile mode is unchecked. to_freeview_args here emits heatscale whenever a layer carries cal_min/cal_max, independent of any percentile concept (which the ViewLayer schema does not even model).
  6. Single-subject MNI space has no usable atlas. In single-subject mode the atlas dropdown only ever lists FreeSurfer subject-space atlases (detect_freesurfer_atlases), even after the space combo is switched to MNI — so the one dropdown atlas silently doesn't overlay the MNI template correctly. Fixed: kind="subject" with space="mni" lists the MNI atlases (with their own LUT) and the subject's T1_<id>_MNI.nii.gz, exactly mirroring the subject-space branch.

Percentile thresholding (v1, pages/viewer/PARITY.md gap #3): a heat-colormap layer built here (TI_max, magnE) carries a percentile window ({"lo": 95, "hi": 99.9}, the Qt tab's own default) instead of a bare cal_min/ cal_max of None. resolve_percentiles fills in the concrete absolute values by reading the NIfTI and computing numpy.percentile over its non-zero voxels — run once by build_view (GET /api/view/{kind}) and again by POST /api/viewers/freeview on whatever ViewSpec the client submits (a user-edited spec may still carry an unresolved percentile window). A layer whose file cannot be read, or whose voxels are all zero, simply keeps cal_min/cal_max as None — the resulting Freeview layer just renders without a heatscale arg, exactly like today.

See Also

tit.catalog : Discovery routines this module's helpers are shared with (mni_resources_dir).

apply_scene_overrides

apply_scene_overrides(scene: dict[str, Any], overrides: dict[str, Any] | None) -> dict[str, Any]

scene, edited in place by overrides; unknown keys are ignored.

The accepted document::

{
  "layers": {"<layerId>": {"visible": bool, "opacity": 0..1,
                           "colormap": str, "showIn3D": bool,
                           "showColorbar": bool, "contoursIn2D": bool,
                           "threshold": {"lo": num|null, "hi": num|null},
                           "colorMode": "tag|field|solid|label",
                           "clip": bool}},
  "layout": "1x1|1+3|2x2|3d-only",
  "camera": "A|P|L|R|S|I",
  "radiological": bool,
  "background": "dark|black|light" | [r, g, b, a]
}

Every value is validated against what the engine's own type accepts and silently dropped otherwise: a stale preset from a saved selection must not turn into a scene the app refuses to open. None returns scene untouched, which is the whole compatibility guarantee -- absent overrides means byte-identical output.

Source code in tit/viewspec.py
def apply_scene_overrides(
    scene: dict[str, Any], overrides: dict[str, Any] | None
) -> dict[str, Any]:
    """*scene*, edited in place by *overrides*; unknown keys are ignored.

    The accepted document::

        {
          "layers": {"<layerId>": {"visible": bool, "opacity": 0..1,
                                   "colormap": str, "showIn3D": bool,
                                   "showColorbar": bool, "contoursIn2D": bool,
                                   "threshold": {"lo": num|null, "hi": num|null},
                                   "colorMode": "tag|field|solid|label",
                                   "clip": bool}},
          "layout": "1x1|1+3|2x2|3d-only",
          "camera": "A|P|L|R|S|I",
          "radiological": bool,
          "background": "dark|black|light" | [r, g, b, a]
        }

    Every value is validated against what the engine's own type accepts and
    silently dropped otherwise: a stale preset from a saved selection must
    not turn into a scene the app refuses to open.  ``None`` returns *scene*
    untouched, which is the whole compatibility guarantee -- absent
    overrides means byte-identical output.
    """
    if not overrides:
        return scene
    by_id = {layer["id"]: layer for layer in scene.get("layers", [])}
    layers = overrides.get("layers")
    if isinstance(layers, dict):
        for layer_id, patch in layers.items():
            layer = by_id.get(str(layer_id))
            if layer is not None and isinstance(patch, dict):
                _apply_layer_override(layer, patch)
    layout = overrides.get("layout")
    if isinstance(layout, str) and layout in SCENE_LAYOUTS:
        scene["layout"] = {"kind": layout, "cells": list(SCENE_LAYOUTS[layout])}
    camera = overrides.get("camera")
    if isinstance(camera, str) and camera.upper() in CAMERA_PRESETS:
        scene["view3d"]["camera"]["rotation"] = list(CAMERA_PRESETS[camera.upper()])
    if "radiological" in overrides:
        scene["radiological"] = bool(overrides["radiological"])
    background = overrides.get("background")
    if isinstance(background, str) and background in SCENE_BACKGROUNDS:
        scene["background"] = list(SCENE_BACKGROUNDS[background])
    elif isinstance(background, (list, tuple)) and len(background) == 4:
        try:
            scene["background"] = [float(c) for c in background]
        except (TypeError, ValueError):
            pass
    # The active layer must still be one that exists and is visible, or the
    # app opens with its inspector pointed at a layer nobody can see.
    visible = [la["id"] for la in scene.get("layers", []) if la["visible"]]
    if visible and scene.get("activeLayerId") not in visible:
        scene["activeLayerId"] = visible[0]
    return scene

build_view

build_view(kind: str, *, subject: str | None = None, simulation: str | None = None, space: str | None = None, field: str | None = None, analysis: str | None = None, atlas: str | None = None, roi: str | None = None, path: str | None = None, extras: list[str] | None = None, overrides: dict[str, Any] | None = None, files: list[str] | None = None) -> dict[str, Any] | None

Build a ViewSpec dict, or None when the request cannot resolve.

None means "unknown subject/simulation/analysis" (the route turns that into a 404); an unrecognised kind also returns None.

extras and overrides are additive and optional (VM, docs/dev/DECISIONS.md § 2026-09-06 (Native panes, job rows and notebooks)). With neither given -- which is every caller that existed before them -- this function returns exactly the document it returned before: extras adds no layer and :func:apply_scene_overrides is not called at all. extras names files to add to the layer list (:data:EXTRA_LAYERS); overrides edits the finished scene (per-layer appearance, layout, camera, convention, background) and is documented on :func:apply_scene_overrides.

Source code in tit/viewspec.py
def build_view(
    kind: str,
    *,
    subject: str | None = None,
    simulation: str | None = None,
    space: str | None = None,
    field: str | None = None,
    analysis: str | None = None,
    atlas: str | None = None,
    roi: str | None = None,
    path: str | None = None,
    extras: list[str] | None = None,
    overrides: dict[str, Any] | None = None,
    files: list[str] | None = None,
) -> dict[str, Any] | None:
    """Build a ``ViewSpec`` dict, or ``None`` when the request cannot resolve.

    ``None`` means "unknown subject/simulation/analysis" (the route turns
    that into a 404); an unrecognised *kind* also returns ``None``.

    *extras* and *overrides* are **additive and optional** (VM,
    ``docs/dev/DECISIONS.md § 2026-09-06 (Native panes, job rows and notebooks)``).  With neither
    given -- which is every caller that existed before them -- this function
    returns exactly the document it returned before: *extras* adds no layer
    and :func:`apply_scene_overrides` is not called at all.  *extras* names
    files to add to the layer list (:data:`EXTRA_LAYERS`); *overrides*
    edits the finished ``scene`` (per-layer appearance, layout, camera,
    convention, background) and is documented on
    :func:`apply_scene_overrides`.
    """
    if kind not in _VIEW_KINDS:
        return None
    # The contract declares exactly two spaces; anything else is the subject's
    # own, which is also what every layer helper falls back to.
    space = "mni" if (space or "").lower() == "mni" else "subject"
    pm = get_path_manager()

    if files is not None:
        # VM2: the client edited the list, so the list *is* the scene. The view
        # type is still built once -- not to contribute layers, but to be the
        # source of each kept file's settings and of the scene's cursor.
        defaults = build_view(
            kind,
            subject=subject,
            simulation=simulation,
            space=space,
            field=field,
            analysis=analysis,
            atlas=atlas,
            roi=roi,
            path=path,
            extras=extras,
        )
        layers = _layers_from_files(files, defaults)
        if not layers:
            return None
        return _apply(
            _finish(
                space,
                layers,
                title=_scene_title(subject, simulation, analysis or field),
                cursor=(defaults or {}).get("cursor"),
            ),
            overrides,
        )

    if kind == "custom":
        if not path:
            return None
        resolved = resolve_jailed(path)
        if resolved is None:
            return None
        resolved_str = str(resolved)
        layer_kind = "label" if resolved_str.endswith(".msh") else "volume"
        return _apply(
            _finish(
                space,
                [_layer(resolved_str, kind=layer_kind)],
                title=os.path.basename(resolved_str),
            ),
            overrides,
        )

    if kind == "subject":
        if not subject or subject not in pm.list_simnibs_subjects():
            return None
        layers = []
        t1 = _subject_t1_layer(pm, subject, space)
        if t1:
            layers.append(t1)
        atlas_layer = _subject_atlas_layer(pm, subject, space, atlas)
        if atlas_layer:
            layers.append(atlas_layer)
        layers.extend(
            _extra_layers(
                pm,
                layers,
                subject=subject,
                simulation=None,
                space=space,
                atlas=atlas,
                extras=extras,
            )
        )
        return _apply(
            _finish(space, layers, title=_scene_title(subject, None, None)), overrides
        )

    if kind == "simulation":
        if not subject or not simulation:
            return None
        if simulation not in pm.list_simulations(subject):
            return None
        sim_dir = pm.simulation(subject, simulation)
        layers = []
        t1 = _subject_t1_layer(pm, subject, space)
        if t1:
            layers.append(t1)
        overlay = _electrode_overlay_layer(pm, subject, simulation)
        if overlay:
            layers.append(overlay)
        if field == "magnE":
            layers.extend(_hf_layers(sim_dir, space))
        else:
            layers.extend(_ti_max_layers(sim_dir, space))
            if space == "subject":
                mesh_layer = _grey_mesh_layer(sim_dir, simulation)
                if mesh_layer:
                    layers.append(mesh_layer)
        cursor = None
        if analysis:
            analysis_layer = _analysis_layer(pm, subject, simulation, analysis)
            if analysis_layer:
                layers.append(analysis_layer)
                cursor = _analysis_cursor(pm, subject, simulation, analysis)
        layers.extend(
            _extra_layers(
                pm,
                layers,
                subject=subject,
                simulation=simulation,
                space=space,
                atlas=atlas,
                extras=extras,
            )
        )
        return _apply(
            _finish(
                space,
                layers,
                title=_scene_title(subject, simulation, analysis or field or "TI_max"),
                cursor=cursor,
            ),
            overrides,
        )

    if kind == "analysis":
        if not subject or not simulation or not analysis:
            return None
        if simulation not in pm.list_simulations(subject):
            return None
        layer = _analysis_layer(pm, subject, simulation, analysis)
        if layer is None:
            return None
        layers = []
        t1 = _subject_t1_layer(pm, subject, "subject")
        if t1:
            layers.append(t1)
        layers.append(layer)
        layers.extend(
            _extra_layers(
                pm,
                layers,
                subject=subject,
                simulation=simulation,
                space="subject",
                atlas=atlas,
                extras=extras,
            )
        )
        return _apply(
            _finish(
                "subject",
                layers,
                title=_scene_title(subject, simulation, analysis),
                cursor=_analysis_cursor(pm, subject, simulation, analysis),
            ),
            overrides,
        )

    if kind == "group":
        layers = []
        template = os.path.join(mni_resources_dir(), MNI_TEMPLATE)
        if os.path.isfile(template):
            layers.append(_layer(template))
        atlas_path = _default_mni_atlas_path(atlas)
        if atlas_path:
            layers.append(
                _layer(
                    atlas_path,
                    colormap="lut",
                    opacity=0.5,
                    lut=_mni_atlas_lut(atlas_path),
                )
            )
        if (
            subject
            and simulation
            and subject in pm.list_simnibs_subjects()
            and simulation in pm.list_simulations(subject)
        ):
            layers.extend(_ti_max_layers(pm.simulation(subject, simulation), "mni"))
        return _apply(
            _finish("mni", layers, title=_scene_title(subject, simulation, "group")),
            overrides,
        )

    return None  # pragma: no cover - _VIEW_KINDS guards this

viewer_candidates

viewer_candidates(subject: str | None = None, simulation: str | None = None, space: str | None = None) -> list[dict[str, Any]]

Everything this subject (and simulation) offers the Viewer's "+ Add…".

Grouped the way a person looks for a file -- the head model, the atlases, the simulation's own outputs, its analyses -- and every entry is a real file that exists right now, with its size, because the point of the list is to choose without guessing. Files that a scene cannot use are not listed at all: FreeSurfer .annot parcellations, .mat matrices, logs and reports are not volumes or meshes.

This is a read: it opens nothing and writes nothing.

Source code in tit/viewspec.py
def viewer_candidates(
    subject: str | None = None,
    simulation: str | None = None,
    space: str | None = None,
) -> list[dict[str, Any]]:
    """Everything this subject (and simulation) offers the Viewer's "+ Add…".

    Grouped the way a person looks for a file -- the head model, the atlases,
    the simulation's own outputs, its analyses -- and every entry is a real
    file that exists right now, with its size, because the point of the list
    is to choose without guessing.  Files that a scene *cannot* use are not
    listed at all: FreeSurfer ``.annot`` parcellations, ``.mat`` matrices,
    logs and reports are not volumes or meshes.

    This is a read: it opens nothing and writes nothing.
    """
    pm = get_path_manager()
    space = "mni" if (space or "").lower() == "mni" else "subject"
    out: list[dict[str, Any]] = []
    if not subject or subject not in pm.list_simnibs_subjects():
        return out

    m2m = pm.m2m(subject)
    for name in sorted(os.listdir(m2m)) if os.path.isdir(m2m) else []:
        candidate = os.path.join(m2m, name)
        if os.path.isfile(candidate) and (
            name.endswith((".nii", ".nii.gz", ".mgz")) or name.endswith(".msh")
        ):
            out.append(_candidate(candidate, "Head model"))

    surfaces = os.path.join(m2m, "surfaces")
    for path in sorted(glob.glob(os.path.join(surfaces, "*.gii"))):
        # The reconstruction surfaces: central/pial/white per hemisphere. The
        # sphere and sphere.reg files are registration targets, not anatomy --
        # offering them would be offering a ball.
        if os.path.basename(path).split(".")[1] in ("central", "pial", "white"):
            out.append(_candidate(path, "Surfaces"))

    seg_dir = os.path.join(m2m, "segmentation")
    manager = VoxelAtlasManager(
        fastsurfer_mri_dir=pm.fastsurfer_mri(subject),
        freesurfer_mri_dir=pm.freesurfer_mri(subject),
        seg_dir=seg_dir,
        masks_dir=pm.masks(subject),
    )
    for _display, path in manager.list_atlases():
        if os.path.isfile(path):
            out.append(_candidate(path, "Atlases"))
    if space == "mni":
        for path in VoxelAtlasManager.detect_mni_atlases(mni_resources_dir()):
            out.append(_candidate(path, "Atlases (MNI)"))
        template = os.path.join(mni_resources_dir(), MNI_TEMPLATE)
        if os.path.isfile(template):
            out.append(_candidate(template, "Head model"))

    if simulation and simulation in pm.list_simulations(subject):
        sim_dir = pm.simulation(subject, simulation)
        for mode in _MODE_DIRS + ("high_Frequency",):
            for sub, group in (
                ("niftis", "Simulation volumes"),
                ("mesh", "Simulation meshes"),
                ("montage_imgs", "Electrodes"),
            ):
                directory = os.path.join(sim_dir, mode, sub)
                for path in sorted(glob.glob(os.path.join(directory, "*"))):
                    if os.path.isfile(path) and path.endswith(
                        (".nii", ".nii.gz", ".mgz", ".msh", ".gii")
                    ):
                        out.append(_candidate(path, group))
        for space_dir in ("Voxel", "Mesh"):
            root = os.path.join(sim_dir, "Analyses", space_dir)
            for path in sorted(glob.glob(os.path.join(root, "*", "*.nii*"))):
                out.append(_candidate(path, "Analyses"))

    seen: set[str] = set()
    unique = []
    for entry in out:
        if entry["path"] in seen:
            continue
        seen.add(entry["path"])
        unique.append(entry)
    return unique

viewer_tree

viewer_tree(subject: str | None = None, space: str | None = None, simulations: list[str] | None = None) -> dict[str, Any]

What the Menu's composition tree draws, for one subject.

simulations is what the person has expanded/selected: analyses are listed only for those, because a subject with a dozen simulations has a dozen Analyses directories and listing all of them turns a menu into a file browser. Anatomy is always listed; it is what a scene starts from.

A read. It opens nothing, writes nothing and reads no voxels.

Source code in tit/viewspec.py
def viewer_tree(
    subject: str | None = None,
    space: str | None = None,
    simulations: list[str] | None = None,
) -> dict[str, Any]:
    """What the Menu's composition tree draws, for one subject.

    *simulations* is what the person has expanded/selected: analyses are listed only for those,
    because a subject with a dozen simulations has a dozen Analyses directories and listing all of
    them turns a menu into a file browser. Anatomy is always listed; it is what a scene starts from.

    A read. It opens nothing, writes nothing and reads no voxels.
    """
    pm = get_path_manager()
    space = "mni" if (space or "").lower() == "mni" else "subject"
    empty = {
        "subject": subject,
        "space": space,
        "anatomy": [],
        "simulations": [],
        "analyses": [],
        "available": False,
        "reason": None,
    }
    if not subject:
        return {**empty, "reason": "no subject chosen"}
    if subject not in pm.list_simnibs_subjects():
        # Named rather than silently empty: "this subject has no head model" is a different
        # problem from "this subject has no simulations", and the Menu should be able to say which.
        return {**empty, "reason": f"{subject} has no head model (m2m directory)"}

    chosen = set(simulations or [])
    all_sims = pm.list_simulations(subject) or []
    sims = [_simulation_branch(pm, subject, name, space) for name in sorted(all_sims)]
    analyses: list[dict[str, Any]] = []
    for name in sorted(all_sims):
        if chosen and name not in chosen:
            continue
        analyses.extend(_analysis_branch(pm, subject, name))

    return {
        "subject": subject,
        "space": space,
        "anatomy": _anatomy_branch(pm, subject, space),
        "simulations": sims,
        "analyses": analyses,
        "available": True,
        "reason": None,
    }

clear_percentile_cache

clear_percentile_cache() -> None

Forget every memoised window. For tests; nothing in the server calls it.

Both in-process caches, because since the two percentile paths were joined (:func:_resolve_layer_percentile) a window can be memoised in either: clearing only one would leave a test asserting "this file is read again" passing for the wrong reason. The on-disk sidecars are not removed -- they live in the project under test's own tmp directory and are keyed by (size, mtime_ns), so they cannot leak between tests.

Source code in tit/viewspec.py
def clear_percentile_cache() -> None:
    """Forget every memoised window. For tests; nothing in the server calls it.

    Both in-process caches, because since the two percentile paths were joined
    (:func:`_resolve_layer_percentile`) a window can be memoised in either: clearing only one
    would leave a test asserting "this file is read again" passing for the wrong reason. The
    on-disk sidecars are *not* removed -- they live in the project under test's own tmp directory
    and are keyed by ``(size, mtime_ns)``, so they cannot leak between tests.
    """
    with _PERCENTILE_LOCK:
        _PERCENTILE_CACHE.clear()
    _stats_cache.clear()
    _bounds_cache.clear()

resolve_percentiles

resolve_percentiles(spec: dict[str, Any]) -> dict[str, Any]

Resolve every layer's percentile window to concrete cal_min/ cal_max, in place, and return spec.

Layers are resolved concurrently on a small thread pool -- reading a NIfTI and computing numpy.percentile both release the GIL for most of their time, so a multi-layer ViewSpec (e.g. TI_max + a high- frequency envelope) stays well under the ~2s budget for a 256^3 volume even when several layers need a percentile scan at once.

Source code in tit/viewspec.py
def resolve_percentiles(spec: dict[str, Any]) -> dict[str, Any]:
    """Resolve every layer's ``percentile`` window to concrete ``cal_min``/
    ``cal_max``, in place, and return *spec*.

    Layers are resolved concurrently on a small thread pool -- reading a
    NIfTI and computing ``numpy.percentile`` both release the GIL for most
    of their time, so a multi-layer ``ViewSpec`` (e.g. TI_max + a high-
    frequency envelope) stays well under the ~2s budget for a 256^3 volume
    even when several layers need a percentile scan at once.
    """
    import concurrent.futures

    pending = [layer for layer in spec.get("layers", []) if layer.get("percentile")]
    if not pending:
        return spec
    with concurrent.futures.ThreadPoolExecutor(max_workers=min(4, len(pending))) as ex:
        list(ex.map(_resolve_layer_percentile, pending))
    return spec

stats_cache_dir

stats_cache_dir() -> str | None

Where the on-disk statistics sidecars live, or None with no project open.

<project>/.ti-toolbox/cache/stats. In the project rather than in a home directory -- the project is the unit people copy and archive, and a window computed from a file belongs with that file's project -- but hidden, because it is regenerable and not a result.

Source code in tit/viewspec.py
def stats_cache_dir() -> str | None:
    """Where the on-disk statistics sidecars live, or ``None`` with no project open.

    ``<project>/.ti-toolbox/cache/stats``. In the project rather than in a home directory --
    the project is the unit people copy and archive, and a window computed from a file belongs
    with that file's project -- but hidden, because it is regenerable and not a result.
    """
    try:
        project = get_path_manager().project_dir
    except Exception:  # noqa: BLE001 - a statistics cache must never break a view
        return None
    if not project:
        return None
    directory = get_path_manager().viewer_stats_cache()
    if not is_within(str(project), directory):
        return None
    return os.path.realpath(directory)

prefetch_volume_stats

prefetch_volume_stats(paths: list[str]) -> None

Warm :func:_volume_stats for paths concurrently.

Reading a NIfTI and computing a percentile both release the GIL for nearly all of their time, so the four volumes of a typical simulation scene cost about as long as the slowest one rather than the sum of all four. This is the cold half of the fix; the sidecar is the warm half.

Source code in tit/viewspec.py
def prefetch_volume_stats(paths: list[str]) -> None:
    """Warm :func:`_volume_stats` for *paths* concurrently.

    Reading a NIfTI and computing a percentile both release the GIL for nearly all of their time,
    so the four volumes of a typical simulation scene cost about as long as the slowest one rather
    than the sum of all four. This is the *cold* half of the fix; the sidecar is the warm half.
    """
    import concurrent.futures

    pending = [p for p in dict.fromkeys(paths) if not _scene_is_mesh(p)]
    if len(pending) < 2:
        for path in pending:
            _volume_stats(path)
        return
    with concurrent.futures.ThreadPoolExecutor(max_workers=min(4, len(pending))) as ex:
        list(ex.map(_volume_stats, pending))

to_tetravox_viewspec

to_tetravox_viewspec(spec: dict[str, Any]) -> dict[str, Any]

The real Tetravox ViewSpec (v2) for an already-resolved spec (pure I/O-wise).

Every file, LUT, colormap, opacity and visibility comes from spec["layers"], which :func:build_view already decided -- this function only reshapes that decision into the engine's vocabulary (plus the read-only voxel-statistics lookups documented on :func:_volume_stats/:func:_volume_scale). A hidden layer's dataset carries no special "lazy" marker (unlike the retired TitScene): a real DatasetRef has no such field, and the embed's own host code decides when to fetch each dataset from ViewSpec.layers[].visible.

Validated against contracts/tetravox-viewspec-v2.schema.json (the hand-written subset this function emits) by tests/test_viewspec_scene.py.

Source code in tit/viewspec.py
def to_tetravox_viewspec(spec: dict[str, Any]) -> dict[str, Any]:
    """The real Tetravox ``ViewSpec`` (v2) for an already-resolved *spec* (pure I/O-wise).

    Every file, LUT, colormap, opacity and visibility comes from
    ``spec["layers"]``, which :func:`build_view` already decided -- this
    function only reshapes that decision into the engine's vocabulary (plus
    the read-only voxel-statistics lookups documented on
    :func:`_volume_stats`/:func:`_volume_scale`). A hidden layer's dataset
    carries no special "lazy" marker (unlike the retired ``TitScene``): a
    real ``DatasetRef`` has no such field, and the embed's own host code
    decides when to fetch each dataset from ``ViewSpec.layers[].visible``.

    Validated against ``contracts/tetravox-viewspec-v2.schema.json`` (the
    hand-written subset this function emits) by
    ``tests/test_viewspec_scene.py``.
    """
    layer_specs = spec.get("layers", [])

    # Read every volume's statistics **at once** rather than one at a time down the layer loop.
    # Each read is an inflate-plus-sort that releases the GIL, so four of them cost about as long
    # as the slowest rather than the sum -- and after the first time they cost a sidecar read.
    prefetch_volume_stats([layer["path"] for layer in layer_specs])

    # A sibling NIfTI field layer's resolved window, reused as the mesh's own
    # approximate scale/threshold (see _mesh_scale_and_threshold), and the volume whose peak the
    # crosshair is placed on.
    field_volumes: list[dict[str, Any]] = [
        layer
        for layer in layer_specs
        if not _scene_is_mesh(layer["path"])
        and _scene_role(layer["path"], layer.get("colormap", "grayscale")) == "field"
        and _volume_stats(layer["path"]) is not None
    ]

    field_peak: list[float] | None = None
    for layer in field_volumes:
        stats = _volume_stats(layer["path"])
        if stats is not None and all(k in stats for k in ("max_x", "max_y", "max_z")):
            field_peak = [stats["max_x"], stats["max_y"], stats["max_z"]]
            break

    datasets: list[dict[str, Any]] = []
    layers: list[dict[str, Any]] = []

    for index, layer in enumerate(layer_specs):
        path = layer["path"]
        name = os.path.basename(path)
        colormap = layer.get("colormap", "grayscale")
        role = _scene_role(path, colormap)
        # A `.gii` sheet used to reach the mesh branch, because `_scene_role` calls anything with
        # a mesh extension a mesh. It is a `surface` now (Tetravox 0.4.0, protocol 3) and never
        # emitted as a mesh: `build_view` refuses the scene rather than lie about the file.
        is_surface = classify_view_file(path) == "surface"
        is_mesh = role == "mesh" and not is_surface
        is_label = colormap == "lut" and not is_mesh and not is_surface
        visible = bool(layer.get("visible", True))
        field_name = _scene_field_name(name) if role in ("mesh", "field") else None

        dataset = _dataset_ref(path, index)
        sidecars: dict[str, Any] = {}
        lut = layer.get("lut")
        if lut and os.path.isabs(str(lut)):
            sidecars["lut"] = _sidecar_ref(str(lut))
        if is_mesh:
            # A SimNIBS .msh carries no $PhysicalNames, so its <mesh>.msh.opt
            # is the only source of tissue names and colours.
            opt = f"{path}.opt"
            if os.path.isfile(opt):
                sidecars["opt"] = _sidecar_ref(opt)
        attachments = [
            attachment
            for attachment in layer.get("attachments", [])
            if os.path.isfile(attachment)
        ]
        if is_surface and attachments:
            # Tetravox §4.6: a surface's `.annot`, morph and data-GIfTI files are re-attached from
            # `sidecars.fields`, in order, before the layers are restored. Each becomes a node
            # field on this dataset, named after the file -- which is what `annotation.name` /
            # `overlay.name` below refer to. There is no attachment *layer*.
            #
            # Relative to the surface's own directory, and `{path}` alone -- the embed lane's
            # contract, and the one place in a scene where a path is not absolute. SimNIBS keeps
            # the parcellations a directory across from the geometry, so these really do come out
            # as `../segmentation/lh.ernie_DK40.annot`.
            sidecars["fields"] = [
                {"path": _relative_sidecar_path(path, a)} for a in attachments
            ]
        if sidecars:
            dataset["sidecars"] = sidecars
        datasets.append(dataset)

        layer_id = f"L{index}"
        base_fields = {
            "id": layer_id,
            "datasetId": dataset["id"],
            # **The file's own basename, exactly as it is on disk.** Maintainer, 2026-09-07:
            # *"Please do not change the name of the files that we load into the viewer. For
            # example, `labeling.nii.gz` should be `labeling.nii.gz` and not [Atlas]."*
            #
            # This used to be a curated label (`Atlas`, `GM · TI_max (volume)`,
            # `Head mesh · magnE · pair 2`). The intent was to explain a layer, and the cost was
            # that the Layers panel no longer named anything a person could find on disk, grep a
            # log for, or match against the "what will open" list they had just composed. A name
            # that cannot be looked up is worse than a name that needs one thing explained.
            #
            # The context has nowhere else to go -- the engine's `LayerBase` (§4.4) has `id`,
            # `datasetId`, `name`, `visible`, `opacity`, `pickable`, `showColorbar` and no
            # description or subtitle field -- so it is dropped rather than smuggled back into the
            # name. `_scene_display_name` is still used, but only where a *human label for
            # choosing* is wanted and the filename is beside it anyway: the Menu's composition
            # tree (`viewer_tree`).
            "name": name,
            "visible": visible,
            "opacity": float(layer.get("opacity", 1.0)),
            "pickable": True,
            "showColorbar": True,
        }

        if is_surface:
            layers.append({**base_fields, **_surface_layer(name, attachments, index)})
        elif is_mesh:
            scale, threshold = _mesh_scale_and_threshold(
                _bounds_for_mesh(name, field_volumes)
            )
            mesh_field = layer.get("mesh_field")
            if mesh_field:
                # The layer named its own node/element field (an analysis overlay's
                # `<field>_ROI`): colour by it from 0 to the value the analysis
                # reported, and hide the exact zeros outside the ROI.
                field_name = str(mesh_field["name"])
                scale = {
                    "kind": "linear",
                    "lo": 0.0,
                    "hi": float(layer.get("cal_max") or 1.0),
                }
                threshold = {
                    "lo": 1e-6,
                    "hi": None,
                    "symmetric": False,
                    "mode": "hide",
                    "softEdge": 0.0,
                }
            layers.append(
                {
                    **base_fields,
                    "kind": "mesh",
                    "colorMode": "field" if field_name else "solid",
                    "solidColor": [0.78, 0.78, 0.8, 1.0],
                    **(
                        {
                            "field": {
                                "source": (
                                    str(mesh_field["source"]) if mesh_field else "elm"
                                ),
                                "name": field_name,
                                "component": "mag",
                            }
                        }
                        if field_name
                        else {}
                    ),
                    "colormap": "jet",
                    "scale": scale,
                    "threshold": threshold,
                    "tagStyle": {},
                    "edges": {"surface": False, "caps": False},
                    "edgeColor": [0.0, 0.0, 0.0, 1.0],
                    "edgeWidthPx": 1.0,
                    "flatShading": False,
                    "faceMode": "cull",
                    "clip": {
                        "planes": [
                            {
                                "plane": {"normal": [1.0, 0.0, 0.0], "offset": 0.0},
                                "enabled": True,
                                # The plane's offset tracks the live cursor
                                # (Scene.cursor), not a value baked in here --
                                # this is what "cursor clip" means for a mesh
                                # layer the server never re-derives per cursor
                                # move.
                                "followCursor": True,
                            }
                        ],
                        "caps": True,
                        "capColorMode": "inherit",
                    },
                    # Grey-matter surfaces read best as an outline over the slices.
                    "contoursIn2D": name.lower().startswith("grey_"),
                    "contourWidthPx": 1.0,
                    "fillIn2D": True,
                }
            )
        else:
            volume_scale, volume_threshold = _volume_window(path, role=role)
            if role == "field":
                # A signed statistic map is windowed symmetrically about zero, so it needs a
                # diverging ramp: `turbo` would give its most saturated colour to the most
                # negative voxel and read as a strong positive finding.
                volume_colormap = "coolwarm" if _is_stat_map(path) else "turbo"
            else:
                volume_colormap = "gray"
            layers.append(
                {
                    **base_fields,
                    "kind": "volume",
                    "volumeIndex": 0,
                    "colormap": volume_colormap,
                    "scale": volume_scale,
                    "threshold": volume_threshold,
                    "interpolation": "nearest" if is_label else "linear",
                    "labelMode": "fill",
                    "outlineWidthPx": 2.0 if is_label else 1.0,
                    "showIn3D": is_label,
                    "precision": "auto",
                }
            )

    # A layout that reserves a 3D pane and a mesh nobody can see is an empty 3D pane -- which is
    # what the maintainer got (2026-09-07: "the 3-D pane empty"). The layout below gives the mesh
    # a pane precisely *because* the scene has one, so the two decisions have to agree: if a mesh
    # is the reason for the 3D pane, the mesh is visible.
    mesh_layers = [la for la in layers if la["kind"] == "mesh"]
    if mesh_layers and not any(la["visible"] for la in mesh_layers):
        mesh_layers[0]["visible"] = True

    visible_ids = [la["id"] for la in layers if la["visible"]]
    active_layer_id = (
        visible_ids[0] if visible_ids else (layers[0]["id"] if layers else None)
    )

    has_mesh = any(la["kind"] == "mesh" for la in layers)
    layout_kind = "3d+1" if has_mesh else "2x2"
    layout_cells = (
        ["view3d", "axial"] if has_mesh else ["axial", "coronal", "sagittal", "view3d"]
    )

    # Where the crosshair lands, in order of how much it knows about what the reader came to see:
    #
    # 1. the spec's own cursor -- an analysis or an optimisation carries its ROI centre, and that
    #    is the exact place the result is *about*;
    # 2. the field's peak voxel -- for a plain simulation there is no ROI, and the hotspot is the
    #    one place a reader always wants first;
    # 3. the scene's bounding-box centre -- no field, so at least land in the middle of the head.
    #
    # What it must not be is the old unconditional `[0, 0, 0]`: world RAS zero is the scanner
    # origin, which for a subject-space head volume is off in a corner of the field of view.
    cursor_raw = spec.get("cursor")
    bounds = _scene_bounds([layer["path"] for layer in layer_specs])
    if cursor_raw:
        cursor = [float(c) for c in cursor_raw]
    elif field_peak is not None:
        cursor = [float(c) for c in field_peak]
    elif bounds is not None:
        cursor = [(bounds[0][i] + bounds[1][i]) / 2.0 for i in range(3)]
    else:
        cursor = [0.0, 0.0, 0.0]
    # The mesh clip plane's offset tracks the scene cursor at load time (the
    # engine keeps it in sync afterwards via followCursor); a scene with no
    # cursor clips through the origin, matching cursor's own [0,0,0] default.
    for layer in layers:
        if layer["kind"] == "mesh":
            layer["clip"]["planes"][0]["plane"]["offset"] = cursor[0]

    return {
        "version": VIEWSPEC_VERSION,
        "datasets": datasets,
        "layers": layers,
        "activeLayerId": active_layer_id,
        # Fitted to the data, not left at the engine's 0.5 mm/px default -- see _fit_mm_per_px for
        # why the engine's own fit never runs for a scene this module writes.
        "slices": [
            dict(
                s,
                camera=(
                    {
                        "center": [0.0, 0.0],
                        "mmPerPx": _fit_mm_per_px(bounds, _FIT_PANE_PX),
                    }
                    if bounds is not None
                    else dict(s["camera"])
                ),
            )
            for s in _DEFAULT_SLICES
        ],
        "view3d": {
            **_DEFAULT_VIEW3D,
            "camera": (
                _fit_camera(dict(_DEFAULT_VIEW3D["camera"]), bounds)
                if bounds is not None
                else dict(_DEFAULT_VIEW3D["camera"])
            ),
        },
        "layout": {"kind": layout_kind, "cells": layout_cells},
        "cursor": cursor,
        "radiological": False,
        "background": list(_DEFAULT_BACKGROUND),
        "lighting": dict(_DEFAULT_LIGHTING),
        "annotations": dict(_DEFAULT_ANNOTATIONS),
        "transparency": dict(_DEFAULT_TRANSPARENCY),
    }

finish_spec

finish_spec(spec: dict[str, Any], *, title: str | None = None, cursor: list[float] | None = None) -> dict[str, Any]

Attach the two derived views of spec -- freeview_args (deprecated, kept for one release) and scene -- in place.

The single place both derivations happen, so GET /api/view/{kind} (via :func:_finish) and POST /api/view/args (on a client-edited spec) can never hand out an argv and a scene built from different rules.

title has nowhere to go in a real ViewSpec (unlike the retired TitScene, it carries no title field) and is accepted only for call- site compatibility with :func:build_view's existing _scene_title plumbing; it is not part of the returned document.

Source code in tit/viewspec.py
def finish_spec(
    spec: dict[str, Any],
    *,
    title: str | None = None,
    cursor: list[float] | None = None,
) -> dict[str, Any]:
    """Attach the two derived views of *spec* -- ``freeview_args`` (deprecated,
    kept for one release) and ``scene`` -- in place.

    The single place both derivations happen, so ``GET /api/view/{kind}``
    (via :func:`_finish`) and ``POST /api/view/args`` (on a client-edited
    spec) can never hand out an argv and a scene built from different rules.

    *title* has nowhere to go in a real ``ViewSpec`` (unlike the retired
    ``TitScene``, it carries no title field) and is accepted only for call-
    site compatibility with :func:`build_view`'s existing ``_scene_title``
    plumbing; it is not part of the returned document.
    """
    if cursor is not None:
        spec["cursor"] = list(cursor)
    spec["freeview_args"] = to_freeview_args(spec)
    spec["scene"] = to_tetravox_viewspec(spec)
    return spec

to_freeview_args

to_freeview_args(spec: dict[str, Any]) -> list[str]

The argv tail Freeview should be launched with for spec.

Reproduces launch_freeview_with_files's per-layer grammar (path:colormap=...:opacity=...:visible=...[:lut=...][:heatscale=lo,hi]), with bug 5 fixed: heatscale is emitted whenever a layer carries both cal_min and cal_max, not only in some percentile-mode branch.

Source code in tit/viewspec.py
def to_freeview_args(spec: dict[str, Any]) -> list[str]:
    """The argv tail Freeview should be launched with for *spec*.

    Reproduces ``launch_freeview_with_files``'s per-layer grammar
    (``path:colormap=...:opacity=...:visible=...[:lut=...][:heatscale=lo,hi]``),
    with bug 5 fixed: ``heatscale`` is emitted whenever a layer carries both
    ``cal_min`` and ``cal_max``, not only in some percentile-mode branch.
    """
    args = []
    for layer in spec.get("layers", []):
        arg = layer["path"]
        arg += f":colormap={layer.get('colormap', 'grayscale')}"
        opacity = layer.get("opacity")
        if opacity is not None:
            arg += f":opacity={opacity}"
        arg += f":visible={1 if layer.get('visible', True) else 0}"
        lut = layer.get("lut")
        if lut:
            arg += f":lut={lut}"
        cal_min, cal_max = layer.get("cal_min"), layer.get("cal_max")
        if cal_min is not None and cal_max is not None:
            arg += f":heatscale={cal_min},{cal_max}"
        args.append(arg)
    return args

freeview_command

freeview_command(spec: dict[str, Any]) -> list[str]

Full ["freeview", ...] argv for spec.

Source code in tit/viewspec.py
def freeview_command(spec: dict[str, Any]) -> list[str]:
    """Full ``["freeview", ...]`` argv for *spec*."""
    return ["freeview"] + to_freeview_args(spec)

jail_roots

jail_roots() -> list[Path]

Directories a viewer/file path is allowed to resolve into.

Source code in tit/viewspec.py
def jail_roots() -> list[Path]:
    """Directories a viewer/file path is allowed to resolve into."""
    pm = get_path_manager()
    roots = []
    if pm.project_dir:
        roots.append(Path(pm.project_dir).resolve())
    roots.append(Path(mni_resources_dir()).resolve().parent)  # resources/
    return roots

raw_jail_roots

raw_jail_roots() -> list[Path]

Directories GET /api/files/raw may stream bytes out of.

The viewer jail, narrowed: the project plus the bundled atlas directory only, not the whole resources/ tree :func:jail_roots allows. The wider root exists so a launcher can hand Freeview any bundled reference file; the raw route hands the browser bytes from the app's own origin, and resources/ also holds patches and scripts that have no business being fetchable there.

Source code in tit/viewspec.py
def raw_jail_roots() -> list[Path]:
    """Directories ``GET /api/files/raw`` may stream bytes out of.

    The viewer jail, narrowed: the project plus the bundled *atlas* directory
    only, not the whole ``resources/`` tree :func:`jail_roots` allows. The
    wider root exists so a launcher can hand Freeview any bundled reference
    file; the raw route hands *the browser* bytes from the app's own origin,
    and ``resources/`` also holds patches and scripts that have no business
    being fetchable there.
    """
    pm = get_path_manager()
    roots = []
    if pm.project_dir:
        roots.append(Path(pm.project_dir).resolve())
    roots.append(Path(mni_resources_dir()).resolve())  # resources/atlas
    return roots

resolve_jailed

resolve_jailed(raw_path: str) -> Path | None

raw_path resolved to an existing file inside :func:jail_roots, or None.

Pure and exception-free by design (domain layer, no HTTP concerns): a caller that needs a 403/404 distinction wraps this; :func:build_view just treats None the same as any other "can't resolve this" case.

Source code in tit/viewspec.py
def resolve_jailed(raw_path: str) -> Path | None:
    """*raw_path* resolved to an existing file inside :func:`jail_roots`, or ``None``.

    Pure and exception-free by design (domain layer, no HTTP concerns): a
    caller that needs a 403/404 distinction wraps this; :func:`build_view`
    just treats ``None`` the same as any other "can't resolve this" case.
    """
    try:
        resolved = os.path.realpath(raw_path)
    except OSError:
        return None
    for root in jail_roots():
        canonical_root = os.path.realpath(root)
        if resolved == canonical_root:
            return None  # a root is a directory, never a servable file
        # Include the separator so a sibling such as project-copy cannot match.
        if resolved.startswith(canonical_root.rstrip(os.sep) + os.sep):
            return Path(resolved) if os.path.isfile(resolved) else None
    return None