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):
- HF glob never matching. The Qt tab globs
high_Frequency/niftis/*_scalar_magnE.nii.gzfor the high-frequency envelope overlay, but every real file is named..._scalar_subject_magnE.nii.gzor..._scalar_MNI_MNI_magnE.nii.gz(verified againstsub-erniein Dataset 000) — the un-suffixed pattern matches nothing, ever. Fixed by globbing*_scalar_*magnE.nii.gzand filtering by the same_MNIsubstring rule used for TI_max niftis. labeling_LUT.txtignored. The Qt tab sends the subject'slabeling.nii.gzatlas overlay withcolormap=lutand no LUT file at all, so regions render with Freeview's arbitrary default palette instead of the SimNIBS tissue colours. Fixed viaVoxelAtlasManager.find_labeling_lut().*_LUT.txtnot 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 themassp2021_labels.txtspecial case for the MASSP atlas (whose sidecar does not share its stem at all). Fixed by trying_LUT.txtfirst, matchingroi_picker._find_volume_lut.- MNI paths hard-coded to the container.
MNI_ATLAS_DIRis 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 fallbackroi_picker._mni_atlas_dir()uses. - Absolute thresholds dropped when percentile mode is off.
launch_freeview_with_filesonly emitsheatscale=...inside theif spec.get("percentile")branch, so a user-set absolute min/max threshold is silently ignored whenever percentile mode is unchecked.to_freeview_argshere emitsheatscalewhenever a layer carriescal_min/cal_max, independent of any percentile concept (which theViewLayerschema does not even model). - 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"withspace="mni"lists the MNI atlases (with their own LUT) and the subject'sT1_<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 ¶
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
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
839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 | |
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
1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 | |
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
clear_percentile_cache ¶
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
resolve_percentiles ¶
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
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
prefetch_volume_stats ¶
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
to_tetravox_viewspec ¶
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
2707 2708 2709 2710 2711 2712 2713 2714 2715 2716 2717 2718 2719 2720 2721 2722 2723 2724 2725 2726 2727 2728 2729 2730 2731 2732 2733 2734 2735 2736 2737 2738 2739 2740 2741 2742 2743 2744 2745 2746 2747 2748 2749 2750 2751 2752 2753 2754 2755 2756 2757 2758 2759 2760 2761 2762 2763 2764 2765 2766 2767 2768 2769 2770 2771 2772 2773 2774 2775 2776 2777 2778 2779 2780 2781 2782 2783 2784 2785 2786 2787 2788 2789 2790 2791 2792 2793 2794 2795 2796 2797 2798 2799 2800 2801 2802 2803 2804 2805 2806 2807 2808 2809 2810 2811 2812 2813 2814 2815 2816 2817 2818 2819 2820 2821 2822 2823 2824 2825 2826 2827 2828 2829 2830 2831 2832 2833 2834 2835 2836 2837 2838 2839 2840 2841 2842 2843 2844 2845 2846 2847 2848 2849 2850 2851 2852 2853 2854 2855 2856 2857 2858 2859 2860 2861 2862 2863 2864 2865 2866 2867 2868 2869 2870 2871 2872 2873 2874 2875 2876 2877 2878 2879 2880 2881 2882 2883 2884 2885 2886 2887 2888 2889 2890 2891 2892 2893 2894 2895 2896 2897 2898 2899 2900 2901 2902 2903 2904 2905 2906 2907 2908 2909 2910 2911 2912 2913 2914 2915 2916 2917 2918 2919 2920 2921 2922 2923 2924 2925 2926 2927 2928 2929 2930 2931 2932 2933 2934 2935 2936 2937 2938 2939 2940 2941 2942 2943 2944 2945 2946 2947 2948 2949 2950 2951 2952 2953 2954 2955 2956 2957 2958 2959 2960 2961 2962 2963 2964 2965 2966 2967 2968 2969 2970 2971 2972 2973 2974 2975 2976 2977 2978 2979 2980 2981 2982 2983 2984 2985 2986 2987 2988 2989 2990 2991 2992 2993 2994 2995 2996 2997 2998 2999 3000 3001 3002 3003 3004 3005 3006 | |
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
to_freeview_args ¶
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
freeview_command ¶
jail_roots ¶
Directories a viewer/file path is allowed to resolve into.
Source code in tit/viewspec.py
raw_jail_roots ¶
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
resolve_jailed ¶
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.