build
tit.scene.build ¶
Building the four scene payloads from a subject's real files (plan §2).
What this module extracts, and from where:
=============== ==========================================================
part / payload source
=============== ==========================================================
skin m2m_<id>/<id>.msh, crop_mesh(tags=[1005])
gm m2m_<id>/<id>.msh, crop_mesh(tags=[1002])
electrodes m2m_<id>/eeg_positions/<net>.csv (Electrode,x,y,z,name)
region labels m2m_<id>/segmentation/{lh,rh}.<id>_<atlas>.annot
+ m2m_<id>/surfaces/{lh,rh}.central.gii
volume legend m2m_<id>/segmentation/labeling_LUT.txt
=============== ==========================================================
The alignment problem, and how it is solved. A .annot file has one
label per vertex of the central surface -- 245 762 per hemisphere on
sub-ernie. The head mesh's grey-matter surface is a different, coarser
triangulation of the same anatomy: 168 952 vertices, and 76 k after §S3
simplification. The label array therefore cannot be handed over as-is; it has
neither the right length nor the right order, and a renderer that assumed it
did would highlight a region several centimetres from the one the user
clicked. :func:build_labels instead
- decodes the
gmpayload the surface route actually serves (not a re-derived vertex list -- a re-derivation is exactly where the two orders could silently diverge), - finds each of those vertices' nearest central-surface vertex, and
- copies that vertex's region across only when it is within
:data:
LABEL_RADIUS_MM.
Step 3 matters because the head mesh's GM tag covers cerebellum and brainstem
as well as cortex, and the cortical atlases cover none of it: measured on
sub-ernie, 7.35 % of GM vertices are more than 5 mm from any central
surface vertex (max 45.7 mm). Those come back label 0 = "no region"
rather than borrowing whatever cortical label happens to be least far away.
Every heavy import (simnibs, nibabel, scipy) is function-local:
all three are MagicMock-ed in tests/conftest.py, so a module-level
import would make tit.scene unimportable in the host suite, and the routes
that only need tvsc/simplify would pay for a SimNIBS import they never
use.
SceneUnavailable ¶
Bases: Exception
A scene payload cannot be built, with a reason a user can act on.
Raised (never a bare FileNotFoundError) so the route layer can turn it
into a 404 whose detail names the missing file -- a subject with no
head model yet is the normal case, not a server error (decision S6: the
page still works without the pane).
source_path ¶
source_path(pm: PathManager, path: str | Path) -> Path | None
A canonical project source, or None for an outward source link.
Scene builders serve project-derived HTTP payloads; standalone scientific readers elsewhere retain their own input policy.
Source code in tit/scene/build.py
head_mesh_path ¶
head_mesh_path(pm: PathManager, sid: str) -> Path
m2m_<id>/<id>.msh, or raise :class:SceneUnavailable.
Source code in tit/scene/build.py
surface_sources ¶
surface_sources(pm: PathManager, sid: str) -> list[str]
Files whose size+mtime fingerprint the skin/gm payloads.
Source code in tit/scene/build.py
surface_fingerprint ¶
surface_fingerprint(pm: PathManager, sid: str) -> str
The cache fingerprint of this subject's surface payloads.
One definition, called by the builder and by the route that looks the result up: computing it in two places is how a builder-version bump would reach one of them and not the other, and the symptom would be a route that rebuilds on every request.
Source code in tit/scene/build.py
annot_paths ¶
{"lh": path, "rh": path} for one cortical atlas; only what exists.
Discovery goes through :class:tit.atlas.mesh.MeshAtlasManager so the
scene sees exactly the atlases GET /api/catalog/atlases lists -- one
definition of "which atlases does this subject have".
atlas_id is checked twice before it reaches the filesystem, and both
checks matter: MeshAtlasManager.find_atlas_file interpolates it into a
glob pattern, and a glob pattern does walk .. segments, so an id
of ../../../../etc/x would otherwise let a caller aim read_annot
at any lh.*.annot on the machine. :func:tit.catalog.is_safe_name
rules out every separator and traversal segment; membership in
list_atlases() then rules out anything this subject does not have.
Returns {} (which every caller turns into a readable 404) rather than
raising, because "this subject has no such atlas" is a normal answer.
Source code in tit/scene/build.py
central_surface_paths ¶
central_surface_paths(pm: PathManager, sid: str) -> dict[str, str]
{"lh": path, "rh": path} for the central surfaces that exist.
Source code in tit/scene/build.py
label_sources ¶
label_sources(pm: PathManager, sid: str, atlas_id: str) -> list[str]
Every file a labels payload depends on, for its fingerprint.
The head mesh is included because the labels are aligned to the gm
surface built from it: a new charm run changes the vertex order, and a
labels file that survived it would be aligned to nothing.
Source code in tit/scene/build.py
labels_fingerprint ¶
labels_fingerprint(pm: PathManager, sid: str, atlas_id: str) -> str
The cache fingerprint of one atlas' labels payload, salted like the rest.
The labels are positions copied out of the gm payload, so a builder
version that changes gm has to invalidate these too -- otherwise the
sidecar's aligned_to_fingerprint names a surface entry that no longer
exists.
Source code in tit/scene/build.py
parse_electrode_csv ¶
Split an eeg_positions/*.csv into electrodes, reference, fiducials.
Rows are <type>,<x>,<y>,<z>,<name> with <type> one of
Electrode / ReferenceElectrode / Fiducial (measured on
sub-ernie's EEG10-10_UI_Jurak_2007.csv: 75 / 1 / 4). Coordinates
are the head mesh's own world millimetres -- the same space the surfaces
are served in, which is what makes an electrode marker land on the skin
rather than floating (pinned by
tests/test_scene_realdata.py::test_every_electrode_sits_on_the_skin).
A malformed row is skipped rather than failing the whole net: these files are written by charm and occasionally carry a trailing blank line.
Source code in tit/scene/build.py
parse_lut_text ¶
[{id, name, color}] from a FreeSurfer-style colour table.
Rule-for-rule the same column-order-agnostic parse as
:func:tit.opt.roi_spec._parse_lut_line, which the optimizer's ROI picker
uses -- tests/test_scene_build.py::test_the_lut_parse_matches_the_roi_pickers
drives both over the same lines so the scene legend and the ROI dropdown
cannot start disagreeing about what a label is called.
Why it is copied rather than imported. tit.opt.roi_spec pulls in
tit.opt.__init__, which imports the ex/flex engines and through them
simnibs: measured 3197 ms in the dev container, against 45 ms for
tit.catalog. Paying three seconds on the first legend request -- more
than the whole 2.5 s warm-cache budget of decision S8 -- to reuse twelve
lines is the wrong trade, and a scene service has no business importing an
optimizer.
color is "#rrggbb", or None when the table carries no RGB
columns.
Source code in tit/scene/build.py
labels_from_nearest ¶
labels_from_nearest(nn_index: ndarray, nn_distance: ndarray, reference_label: ndarray, radius: float) -> ndarray
Per-vertex uint16 wire labels from a nearest-neighbour lookup.
Pure array arithmetic, deliberately separated from the scipy KD-tree
that produces nn_index/nn_distance: this is the step where an
off-by-one or a forgotten radius check turns into "clicking a region
highlights the wrong one", and it is testable on the host where scipy
is mocked.
Source code in tit/scene/build.py
one_ring_mode_filter ¶
one_ring_mode_filter(labels: ndarray, triangles: ndarray, passes: int = LABEL_SMOOTH_PASSES) -> ndarray
Replace every vertex label a majority of its one-ring disagrees with.
:func:labels_from_nearest decides each simplified GM vertex on its own,
from the single nearest central-surface vertex. Near a region boundary the
two surfaces are a millimetre or two apart, so that lookup crosses the
border for isolated vertices: measured on sub-ernie/DK40, 115
vertices had no neighbour sharing their label at all and 3 357 were
in a minority of their own one-ring. Each of those is a stray triangle of
a wrong colour, and each border vertex that flips adds a triangle-sized
spike to the border -- the "ragged saw-tooth" the maintainer reported on
2026-09-06.
The filter is the standard cure: a vertex takes the most common label
among its mesh neighbours, keeping its own on a tie (so a genuine
one-vertex peninsula that the surface really has is not shaved off, and a
two-region border does not oscillate between passes). NO_REGION is
just another label here, which keeps the unlabelled cerebellum/brainstem
patch compact instead of letting cortical labels fray into it.
Deterministic and pure numpy -- scipy is a MagicMock in the
host suite. Ties among other labels go to the lowest id.
Source code in tit/scene/build.py
signed_volume ¶
The volume a closed triangle surface encloses (mm3), signed by winding.
The divergence theorem on the field x: V = (1/3) * closed-integral
of x . n dA, which for a triangle soup is
sum_t (a - p) . ((b - a) x (c - a)) / 6. Positive when the
right-hand (counter-clockwise) normals point out of the enclosed volume,
negative when they point in -- and that sign is the criterion
:func:orient_outward acts on. It is independent of the reference point
p for a closed surface (the area-weighted normals of a closed surface
sum to zero), so p is taken to be the vertex centroid, which is what
keeps the number meaningful when the surface has a small hole: the head
mesh's skin is cut off at the neck, and ernie's served gm has 3 370
boundary edges after simplification.
This is deliberately not the criterion the renderer or the pick uses. It is an independent one: a rasteriser asks "is the nearest fragment front-facing", this asks "does the surface enclose a positive volume", and the two agree only if the winding really is right.
Source code in tit/scene/build.py
orient_outward ¶
(triangles wound outward, whether they had to be flipped).
Why this exists, measured on sub-ernie on 2026-09-04 (lane FIX-A,
docs/dev/DECISIONS.md § 2026-09-04 (Scene service and retained pages) §5.1, reproduced by CL1): the head
mesh's tag-1005 (skin) surface elements come out of crop_mesh wound
outward (signed volume +4 841 347 mm3, 99.5 % of triangle normals
pointing away from the centroid) and its tag-1002 (grey matter) elements
come out wound inward (-1 309 124 mm3, 32.6 %). Nothing in the head
mesh promises a consistent convention between tissue boundaries, so the
builder establishes one rather than each consumer guessing.
What the inward winding broke: any translucent shell renderer or normal
consumer that assumes outward faces will composite or shade an inverted
gm surface inside-out. The scene service establishes the convention at
the source instead of making each downstream renderer guess.
A flip swaps two of the three indices, so the vertex positions -- and therefore every per-vertex label, electrode alignment and cache-fingerprint property -- are untouched.
Source code in tit/scene/build.py
focus_bbox ¶
[x0,y0,z0,x1,y1,z1] over the vertices at or above floor_z.
The framing hint decision S7's pane needs, and the reason it is computed
here rather than in the renderer: only the server has the anatomy. A head
model's skin surface runs down the neck to the shoulders -- sub-ernie's
reaches z = -128.9 mm while its grey matter starts at -50.7 -- so a
pane that frames the manifest's bbox spends about a fifth of its height
on neck. floor_z is the lowest grey-matter vertex
(:data:FOCUS_FLOOR_PART), i.e. the bottom of the cerebellum and
brainstem, which is the lowest point of the head anyone picking electrodes
or regions is looking at.
Returns the part's full box when nothing is below the floor (the grey
matter's own contribution is always its whole box), so a caller can union
the parts' focus_bbox exactly the way it unions their bbox.
Source code in tit/scene/build.py
build_surfaces ¶
build_surfaces(pm: PathManager, sid: str) -> dict[str, dict]
Build, cache and describe both surface parts for sid.
Both parts come out of one read_msh (measured 1.26 s for a 184 MB
mesh, versus 0.03-0.10 s per crop): building them separately would double
the only expensive step and the ~600 MB of RSS it needs.
Returns {part: sidecar-dict}; the bytes are in the cache.
Source code in tit/scene/build.py
602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 | |
atlas_region_count ¶
atlas_region_count(pm: PathManager, sid: str, atlas_id: str) -> int
How many named regions one cortical atlas has, over both hemispheres.
Read straight from the .annot colour tables rather than cached: 85 ms
for all three of sub-ernie's atlases (measured 2026-09-04), which the
manifest can afford, and a cached count would be one more thing that can
go stale against the annotation files.
Source code in tit/scene/build.py
build_labels ¶
build_labels(pm: PathManager, sid: str, atlas_id: str) -> dict
Build and cache the uint16 labels payload aligned to part gm.
The gm vertices are read back out of the cached gm payload, so the
labels are aligned to the exact bytes GET /api/scene/surface?part=gm
serves. Deriving the vertex order a second time from the mesh would be the
one place the two could silently disagree.
Source code in tit/scene/build.py
read_net ¶
read_net(pm: PathManager, sid: str, net: str) -> dict
One EEG net's electrode positions in the surfaces' own world space.
Cheap enough (75-256 rows of CSV) that it is never cached: the cost is a file read, and a cache entry would only add a way to serve stale positions after an electrode file is regenerated.
Source code in tit/scene/build.py
volume_legend ¶
volume_legend(pm: PathManager, sid: str, volume_id: str = 'labeling') -> dict
{entries:[{id,name,color}]} for the subject's label volume.