diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..f0a25530a85a1f7c6940527d8be9ab6bed1deb19 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.git +.idea +.venv +__pycache__ +*.pyc +*.pyo +.env +data/meshes diff --git a/.gitattributes b/.gitattributes index a6344aac8c09253b3b630fb776ae94478aa0275b..2f1b0dc5fe9645e74d3377de78bc5956d4e66f87 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,35 +1,6 @@ -*.7z filter=lfs diff=lfs merge=lfs -text -*.arrow filter=lfs diff=lfs merge=lfs -text +*.obj filter=lfs diff=lfs merge=lfs -text *.bin filter=lfs diff=lfs merge=lfs -text -*.bz2 filter=lfs diff=lfs merge=lfs -text -*.ckpt filter=lfs diff=lfs merge=lfs -text -*.ftz filter=lfs diff=lfs merge=lfs -text -*.gz filter=lfs diff=lfs merge=lfs -text -*.h5 filter=lfs diff=lfs merge=lfs -text -*.joblib filter=lfs diff=lfs merge=lfs -text -*.lfs.* filter=lfs diff=lfs merge=lfs -text -*.mlmodel filter=lfs diff=lfs merge=lfs -text -*.model filter=lfs diff=lfs merge=lfs -text -*.msgpack filter=lfs diff=lfs merge=lfs -text *.npy filter=lfs diff=lfs merge=lfs -text *.npz filter=lfs diff=lfs merge=lfs -text -*.onnx filter=lfs diff=lfs merge=lfs -text -*.ot filter=lfs diff=lfs merge=lfs -text -*.parquet filter=lfs diff=lfs merge=lfs -text -*.pb filter=lfs diff=lfs merge=lfs -text -*.pickle filter=lfs diff=lfs merge=lfs -text -*.pkl filter=lfs diff=lfs merge=lfs -text -*.pt filter=lfs diff=lfs merge=lfs -text -*.pth filter=lfs diff=lfs merge=lfs -text -*.rar filter=lfs diff=lfs merge=lfs -text -*.safetensors filter=lfs diff=lfs merge=lfs -text -saved_model/**/* filter=lfs diff=lfs merge=lfs -text -*.tar.* filter=lfs diff=lfs merge=lfs -text -*.tar filter=lfs diff=lfs merge=lfs -text -*.tflite filter=lfs diff=lfs merge=lfs -text -*.tgz filter=lfs diff=lfs merge=lfs -text -*.wasm filter=lfs diff=lfs merge=lfs -text -*.xz filter=lfs diff=lfs merge=lfs -text -*.zip filter=lfs diff=lfs merge=lfs -text -*.zst filter=lfs diff=lfs merge=lfs -text -*tfevents* filter=lfs diff=lfs merge=lfs -text +*.gz filter=lfs diff=lfs merge=lfs -text +*.ply filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..9460c99c4ad8c258676229d75eee25009b72832a --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +*.pyo +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..c9b796260c06a5859a4b2ad72e1295511158b9e5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,39 @@ +FROM python:3.11-slim + +ENV DEBIAN_FRONTEND=noninteractive \ + DISPLAY=:99 \ + LIBGL_ALWAYS_SOFTWARE=1 \ + MESA_GL_VERSION_OVERRIDE=3.3 \ + PIP_NO_CACHE_DIR=1 \ + PYTHONUNBUFFERED=1 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + libegl1 \ + libgl1 \ + libgl1-mesa-dri \ + libglib2.0-0 \ + libglu1-mesa \ + libsm6 \ + libx11-6 \ + libxext6 \ + libxrender1 \ + libxt6 \ + xvfb \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app/mindVisualizer +COPY . /app/mindVisualizer + +RUN python -m pip install --upgrade pip setuptools wheel && \ + python -m pip install \ + numpy scipy vtk \ + langchain langchain-openai langchain-community \ + faiss-cpu python-dotenv certifi tiktoken openai \ + brainglobe-atlasapi nibabel nilearn scikit-image \ + trame trame-vtk trame-vuetify && \ + chmod +x /app/mindVisualizer/start.sh + +EXPOSE 7860 + +CMD ["/app/mindVisualizer/start.sh"] diff --git a/README.md b/README.md index ce0380007c1a4a4b035aa6b54448d3f678b4ad83..95412ca180183c5501ed0cff3130afbb6f4f5a9c 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,11 @@ ---- -title: Mindvisualizer Vtk -emoji: 😻 -colorFrom: purple -colorTo: green +--- +title: MindVisualizer Live +colorFrom: green +colorTo: blue sdk: docker pinned: false --- -Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference +# MindVisualizer Live + +Runs the Python/VTK MindVisualizer runtime through a Trame web transport. diff --git a/app.py b/app.py new file mode 100644 index 0000000000000000000000000000000000000000..9e2a6cae787d43412a562fa8e488ad30c7df776a --- /dev/null +++ b/app.py @@ -0,0 +1,676 @@ +#!/usr/bin/env python3 +"""HF-safe live MindVisualizer wrapper. + +This is a web transport for the Python/VTK runtime. It imports the original +project modules for field loading, particle helpers, mesh overlay, probes, and +GPT analysis instead of reimplementing the algorithms in TypeScript. +""" + +from __future__ import annotations + +import argparse +import asyncio +import os +import sys +import threading +from pathlib import Path + +import numpy as np +import vtk +from scipy.spatial import cKDTree +from vtkmodules.util.numpy_support import numpy_to_vtk +from vtkmodules.vtkInteractionStyle import vtkInteractorStyleTrackballCamera + +PROJECT_ROOT = Path(__file__).resolve().parent +DATA_DIR = PROJECT_ROOT / "data" +MDN_DIR = DATA_DIR / "mdn" +MESH_DIR = DATA_DIR / "meshes" +ALIGNMENT_FILE = DATA_DIR / "brain_alignment.json" +DEFAULT_META = MDN_DIR / "mdn_particles_rdcim_teacher_edge_hq_grid125_meta.json" +DEFAULT_OOS = MDN_DIR / "mdn_particles_rdcim_teacher_edge_hq_training_points.npy" + +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from src.colormaps import bicolor_white_center, rainbow_rgb01, turbo_rgb01 +from src.field_loader import TriLinearSampler, load_field, load_points_any +from src.main import ( + VtkTextOverlay, + add_window_legend, + build_cloud, + compute_fraction_mask, + densify_oos_surface, + lattice_positions, + wrap_inside, +) +from src.mesh_overlay import FlowMeshOverlay +from src.probe import ProbeSystem +from src.region_analyzer import analyze_probe_path, analyze_with_gpt + + +def ensure_ssl() -> None: + try: + import certifi + + cert_file = certifi.where() + for name in ("SSL_CERT_FILE", "REQUESTS_CA_BUNDLE"): + current = os.environ.get(name, "") + if not current or not os.path.isfile(current): + os.environ[name] = cert_file + except Exception: + pass + + +class LiveMindVisualizer: + def __init__(self, args: argparse.Namespace): + self.args = args + self.rng = np.random.default_rng(2025) + self.paused = False + self.field_mode = "mean" + self.colour_mode = "SPEED" + self.cmap_mode = "TURBO" + self.filter_mode = "OFF" + self.filter_top = True + self.filter_frac = 1.0 + self.clip_on = False + self.clip_frac = 1.0 + self.mdn_alpha = 1.0 + self.oos_visible = False + self.probe_busy = False + self.gpt_pending: str | None = None + self._load_data() + self._setup_vtk() + + def _load_data(self) -> None: + self.field = load_field(self.args.meta) + self.G = self.field["G"] + self.amin = self.field["amin"] + self.amax = self.field["amax"] + self.V_mean = self.field["mean"] + self.MUS = self.field["mus"] + self.PI = self.field["pi"] + self.ENT = self.field["ENT"] + self.K = len(self.MUS) + + self.sampler_mean = TriLinearSampler(self.V_mean, self.amin, self.amax) + self.diag = float(np.linalg.norm(self.amax - self.amin)) + self.target_step = self.args.max_step_frac * max(self.diag, 1e-6) + self.vmax_mean = self._interior_percentile(self.V_mean) + + self.samplers_comp = [TriLinearSampler(mu, self.amin, self.amax) for mu in self.MUS] + self.sampler_gated = None + self.V_gated = None + if self.PI is not None and self.PI.ndim == 4 and self.PI.shape[-1] == self.K and self.K > 0: + idx = np.argmax(self.PI, axis=-1) + self.V_gated = np.zeros_like(self.MUS[0], dtype=np.float32) + for k_i in range(self.K): + self.V_gated += self.MUS[k_i] * (idx == k_i)[..., None].astype(np.float32) + self.sampler_gated = TriLinearSampler(self.V_gated, self.amin, self.amax) + + self.field_modes = ["mean"] + if self.sampler_gated is not None: + self.field_modes.append("gated") + self.field_modes += [f"comp{i + 1}" for i in range(self.K)] + + self.sampler_by_name = {"mean": self.sampler_mean} + if self.sampler_gated is not None: + self.sampler_by_name["gated"] = self.sampler_gated + for i, sampler in enumerate(self.samplers_comp): + self.sampler_by_name[f"comp{i + 1}"] = sampler + + self.vmax_by_name = {"mean": self.vmax_mean} + if self.V_gated is not None: + self.vmax_by_name["gated"] = self._interior_percentile(self.V_gated) + for i, mu in enumerate(self.MUS): + self.vmax_by_name[f"comp{i + 1}"] = self._interior_percentile(mu) + + self.ent_min = self.ent_max = 0.0 + if self.ENT is not None: + self.ent_min = float(np.min(self.ENT)) + self.ent_max = float(np.max(self.ENT)) + + self.oos_pts = None + if self.args.oos is not None and Path(self.args.oos).exists(): + self.oos_pts = load_points_any(Path(self.args.oos)).astype(np.float32) + + self._seed_particles() + self._prepare_oos_distance() + + def _interior_percentile(self, vec: np.ndarray, q: float = 100.0) -> float: + mag = np.linalg.norm(vec.reshape(-1, 3), axis=1) + nz = mag[mag > 0] + return float(np.percentile(nz, q)) if nz.size else 1.0 + + def _seed_particles(self) -> None: + overlap_radius = float(self.args.overlap_frac) * max(self.diag, 1e-9) + overlap_sigma = float(self.args.overlap_jitter) * max(self.diag, 1e-9) + self.seed_mode = "grid" + self.seed_points = None + self.n_axis = None + + train = self.field["TRAIN"] + if train is not None and self.oos_pts is not None and len(train) > 0 and len(self.oos_pts) > 0: + try: + tree = cKDTree(train.astype(np.float32)) + dists, _ = tree.query(self.oos_pts.astype(np.float32), k=1) + candidates = self.oos_pts[dists <= overlap_radius] + if candidates.size > 0: + self.seed_mode = "overlap" + if self.args.overlap_limit and len(candidates) > self.args.overlap_limit: + idx = self.rng.choice(len(candidates), size=int(self.args.overlap_limit), replace=False) + candidates = candidates[idx] + self.seed_points = candidates.astype(np.float32) + except Exception as exc: + print("[seed] overlap failed:", exc) + + if self.seed_mode == "overlap" and self.seed_points is not None: + if self.args.oos_seed_limit > 0 and len(self.seed_points) > self.args.oos_seed_limit: + idx = self.rng.choice(len(self.seed_points), size=self.args.oos_seed_limit, replace=False) + self.seed_points = self.seed_points[idx] + if self.args.oos_fill_count > 0 and self.oos_pts is not None: + extras = densify_oos_surface( + base_pts=self.seed_points, + full_oos_pts=self.oos_pts, + amin=self.amin, + amax=self.amax, + extra_count=self.args.oos_fill_count, + ) + if extras is not None and len(extras) > 0: + self.seed_points = np.concatenate([self.seed_points, extras], axis=0).astype(np.float32) + + if self.seed_mode == "overlap" and self.seed_points is not None: + if overlap_sigma > 0: + jitter = self.rng.standard_normal(self.seed_points.shape).astype(np.float32) * overlap_sigma + self.P0 = np.clip(self.seed_points + jitter, self.amin, self.amax) + else: + self.P0 = np.clip(self.seed_points, self.amin, self.amax) + else: + self.n_axis = max(2, self.G // max(1, self.args.stride)) + self.P0 = lattice_positions( + self.n_axis, + self.amin, + self.amax, + margin=0.02, + jitter=self.args.respawn_jitter, + seed=2025, + ) + + self.P = self.P0.copy() + self.Np = len(self.P) + self.base_ttl_lo = max(8, 60 // 2) + self.base_ttl_hi = 60 + self.ttl_scale = 1.0 + self.ttl = self._sample_ttl(self.Np) + self.ages = self.rng.integers(0, np.maximum(1, self.ttl), size=self.Np, dtype=np.int32) + self.ent_prev = self.sampler_mean.sample_scalar(self.ENT, self.P).astype(np.float32) if self.ENT is not None else None + self.ent_d_ema = np.zeros(self.Np, np.float32) if self.ENT is not None else None + + def _sample_ttl(self, n: int) -> np.ndarray: + base = self.rng.integers(self.base_ttl_lo, self.base_ttl_hi + 1, size=int(n)) + return np.maximum(1, np.round(base * self.ttl_scale)).astype(np.int32) + + def _prepare_oos_distance(self) -> None: + self.OOS_DIST = None + self.sampler_oosdist = None + self.far_oos_params = None + if self.args.no_far_from_oos or self.oos_pts is None or len(self.oos_pts) == 0: + return + Ng = int(max(8, self.args.oos_dist_grid)) + tree_oos = cKDTree(self.oos_pts.astype(np.float32)) + gridP = lattice_positions(Ng, self.amin, self.amax, margin=0.0, jitter=0.0, seed=0) + dists, _ = tree_oos.query(gridP, k=1) + self.OOS_DIST = dists.reshape(Ng, Ng, Ng).astype(np.float32) + self.sampler_oosdist = TriLinearSampler(np.zeros((Ng, Ng, Ng, 3), np.float32), self.amin, self.amax) + self.far_oos_params = { + "d0": float(self.args.oos_dist_thresh_frac) * self.diag, + "gamma": float(self.args.oos_dist_gamma), + "boost": float(self.args.oos_death_boost), + } + + def _setup_vtk(self) -> None: + init_rgba = np.tile(np.array([[255, 255, 255, 48]], np.uint8), (self.Np, 1)) + self.points, self.color_arr, self.poly, self.p_actor = build_cloud(self.P, point_size=2.0, rgba=init_rgba) + + self.ren = vtk.vtkRenderer() + self.ren.SetBackground(0, 0, 0) + self.ren.AddActor(self.p_actor) + + self.win = vtk.vtkRenderWindow() + self.win.SetOffScreenRendering(1) + self.win.AddRenderer(self.ren) + self.win.SetSize(self.args.window_size[0], self.args.window_size[1]) + self.win.SetWindowName("mindVisualizer Live") + + self.iren = vtk.vtkRenderWindowInteractor() + self.iren.SetRenderWindow(self.win) + self.iren.SetInteractorStyle(vtkInteractorStyleTrackballCamera()) + self.iren.Initialize() + + self.o_actor = None + if self.oos_pts is not None and len(self.oos_pts) > 0: + rgb_oos = np.tile(np.array([[255, 255, 255]], np.uint8), (len(self.oos_pts), 1)) + oos_rgba = np.concatenate([rgb_oos, np.full((len(self.oos_pts), 1), 72, np.uint8)], axis=1) + _, _, _, self.o_actor = build_cloud(self.oos_pts, point_size=1.5, rgba=oos_rgba, opacity=72 / 255.0) + + self.flow_mesh = None + try: + self.flow_mesh = FlowMeshOverlay(ren=self.ren, win=self.win, mesh_dir=MESH_DIR, alignment_file=ALIGNMENT_FILE) + grid_cache = DATA_DIR / "label_grid_cache.npz" + if not self.flow_mesh.load_label_grid(grid_cache): + self.flow_mesh.build_label_grid() + self.flow_mesh.save_label_grid(grid_cache) + if self.flow_mesh.get_all_region_keys(): + self.flow_mesh.cycle(+1) + except Exception as exc: + print("[flow mesh] disabled:", exc) + + self.text_overlay = VtkTextOverlay(self.ren) + self.probe_sys = ProbeSystem(self.ren, self.win, self.amin, self.amax) + if self.flow_mesh is not None: + self.probe_sys.set_mesh_overlay(self.flow_mesh) + self.probe_sys.set_multi_count(self.args.multi_probe) + if self.args.branching and self.PI is not None: + self.probe_sys.set_branching(True, pi_field=self.PI, mus_samplers=self.samplers_comp) + + add_window_legend( + self.ren, + [ + "mindVisualizer live Python/VTK", + "space pause | f field | v colour | m cmap | q/w mesh | hide", + "[ ] dt | +/- speed | 1/2 lifetime | 7/8 alpha | y/u/z/x filter", + "o OOS | j clip | a/d frac | probe | analyze | clear", + ], + font_px=12, + ) + self.ren.ResetCamera() + for _ in range(12): + V = self.sampler_mean.sample_vec(self.P) + self.P = wrap_inside( + self.P + V * (self.args.dt * self.args.speed_scale) * (self.target_step / max(self.vmax_mean, 1e-9)), + self.amin, + self.amax, + margin_frac=self.args.margin, + ) + if self.ENT is not None: + self.ent_prev = self.sampler_mean.sample_scalar(self.ENT, self.P).astype(np.float32) + self.ent_d_ema[:] = 0.0 + self.win.Render() + + def current_sampler(self) -> TriLinearSampler: + return self.sampler_by_name.get(self.field_mode, self.sampler_mean) + + def vmax_for_mode(self) -> float: + return self.vmax_by_name.get(self.field_mode, self.vmax_mean) + + def map_rgb_from_t(self, t01: np.ndarray) -> np.ndarray: + return rainbow_rgb01(t01) if self.cmap_mode == "RAINBOW" else turbo_rgb01(t01) + + def apply_colors(self, speed_vals: np.ndarray, P_world: np.ndarray, dent_vals=None, vis_mask=None) -> None: + eps = 1e-12 + if self.colour_mode == "ENTROPY" and self.ENT is not None: + scal = self.sampler_mean.sample_scalar(self.ENT, P_world) + t = (scal - self.ent_min) / max(self.ent_max - self.ent_min, eps) + base_rgb = self.map_rgb_from_t(np.clip(t, 0, 1)) + elif self.colour_mode == "DELTA_ENTROPY" and dent_vals is not None: + a = np.abs(dent_vals) + scale = float(np.percentile(a, 97)) if a.size else 1.0 + if not np.isfinite(scale) or scale <= eps: + scale = 1.0 + base_rgb = self.map_rgb_from_t(np.clip(a / scale, 0, 1)) + elif self.colour_mode == "DIR_DELTA_ENTROPY" and dent_vals is not None: + base_rgb = bicolor_white_center(dent_vals) + else: + smin = float(np.min(speed_vals)) if speed_vals.size else 0.0 + smax = float(np.max(speed_vals)) if speed_vals.size else 1.0 + t = (speed_vals - smin) / max(smax - smin, eps) + base_rgb = self.map_rgb_from_t(np.clip(t, 0, 1)) + + alpha = int(np.clip(255.0 * self.mdn_alpha, 5, 255)) + a = np.full(len(base_rgb), alpha, np.uint8) + if vis_mask is not None: + a = a.copy() + a[~vis_mask] = 0 + rgba = np.concatenate([base_rgb, a[:, None]], axis=1).astype(np.uint8) + self.color_arr.DeepCopy(numpy_to_vtk(rgba, deep=True)) + self.color_arr.Modified() + self.poly.Modified() + + def step(self) -> None: + if self.gpt_pending is not None: + result = self.gpt_pending + self.gpt_pending = None + self.probe_busy = False + self.text_overlay.show_gpt(result) + self.text_overlay.add_log("GPT analysis complete") + + if self.paused: + self.win.Render() + return + + sampler = self.current_sampler() + Vraw = sampler.sample_vec(self.P) + vmx = float(self.vmax_for_mode()) + if self.clip_on: + thr = float(max(1e-9, self.clip_frac * vmx)) + speed = np.linalg.norm(Vraw, axis=1) + k = np.minimum(1.0, thr / (speed + 1e-9)).astype(np.float32) + Vstep = Vraw * k[:, None] + eff_vmax = min(vmx, thr) + else: + Vstep = Vraw + eff_vmax = vmx + + step_size = self.args.dt * self.args.speed_scale * (self.target_step / max(eff_vmax, 1e-9)) + self.P = wrap_inside(self.P + Vstep * step_size, self.amin, self.amax, margin_frac=self.args.margin) + + dent = None + if self.ENT is not None: + ent_now = self.sampler_mean.sample_scalar(self.ENT, self.P).astype(np.float32) + d = ent_now - self.ent_prev + self.ent_d_ema = (1.0 - 0.2) * self.ent_d_ema + 0.2 * d + self.ent_prev = ent_now + dent = self.ent_d_ema + + age_inc = np.ones(len(self.P), np.int32) + if self.sampler_oosdist is not None and self.OOS_DIST is not None and self.far_oos_params is not None: + d_oos = self.sampler_oosdist.sample_scalar(self.OOS_DIST, self.P).astype(np.float32) + d0 = max(1e-9, self.far_oos_params["d0"]) + t = np.clip((d_oos - d0) / d0, 0.0, 1.0) + gate = np.power(t, float(max(0.1, self.far_oos_params["gamma"]))).astype(np.float32) + age_inc += np.floor(gate * float(max(0.0, self.far_oos_params["boost"])) + 1e-9).astype(np.int32) + self.ages += age_inc + + dead = self.ages >= self.ttl + if np.any(dead): + if self.seed_mode == "overlap" and self.seed_points is not None and len(self.seed_points) > 0: + sel = self.rng.integers(0, len(self.seed_points), size=dead.sum()) + base = self.seed_points[sel] + if self.args.overlap_jitter > 0: + sigma = float(self.args.overlap_jitter) * max(self.diag, 1e-9) + jitter = self.rng.standard_normal((dead.sum(), 3)).astype(np.float32) * sigma + self.P[dead] = np.clip(base + jitter, self.amin, self.amax) + else: + self.P[dead] = np.clip(base, self.amin, self.amax) + else: + jitter_world = (self.amax - self.amin) * (self.args.respawn_jitter / max((self.n_axis or 2) - 1, 1)) + jitter = (self.rng.random((dead.sum(), 3)).astype(np.float32) - 0.5) * 2.0 * jitter_world + self.P[dead] = np.clip(self.P0[dead] + jitter, self.amin, self.amax) + self.ages[dead] = 0 + self.ttl[dead] = self._sample_ttl(dead.sum()) + if self.ENT is not None: + self.ent_prev[dead] = self.sampler_mean.sample_scalar(self.ENT, self.P[dead]).astype(np.float32) + self.ent_d_ema[dead] = 0.0 + + if self.probe_sys.active: + self.probe_sys.step(sampler, step_size) + + speed = np.linalg.norm(Vstep, axis=1) + if self.filter_mode == "OFF": + vis_mask = np.ones(len(self.P), dtype=bool) + else: + vis_mask = compute_fraction_mask(speed, self.filter_frac, top=self.filter_top) + + self.points.SetData(numpy_to_vtk(self.P, deep=True)) + self.points.Modified() + self.apply_colors(speed, self.P, dent_vals=dent, vis_mask=vis_mask) + self.win.Render() + + def cycle_field(self) -> str: + idx = self.field_modes.index(self.field_mode) + self.field_mode = self.field_modes[(idx + 1) % len(self.field_modes)] + return self.field_mode + + def cycle_colour(self) -> str: + modes = ["SPEED"] + if self.ENT is not None: + modes += ["ENTROPY", "DELTA_ENTROPY", "DIR_DELTA_ENTROPY"] + idx = modes.index(self.colour_mode) if self.colour_mode in modes else 0 + self.colour_mode = modes[(idx + 1) % len(modes)] + return self.colour_mode + + def place_probe_center(self) -> None: + pos = ((self.amin + self.amax) * 0.5).astype(np.float32) + self.probe_sys.place(pos) + self.text_overlay.add_log("Probe placed at brain center") + + def analyze_probe(self, use_rag: bool) -> None: + if not self.probe_sys.active or self.flow_mesh is None or self.probe_busy: + self.text_overlay.add_log("No probe ready to analyze") + return + self.probe_sys.freeze() + snapshots = [] + for probe in self.probe_sys.get_all_probes(): + if not probe.path or len(probe.path) < 3: + continue + snapshots.append( + { + "path": probe.get_path_array().copy(), + "field_mags": probe.get_field_mags_array().copy(), + "ghost": probe.ghost, + "label": probe.label, + } + ) + if not snapshots: + self.text_overlay.add_log("No probe data") + return + self.probe_busy = True + self.text_overlay.add_log("Analyzing probe path...") + threading.Thread(target=self._analyze_async, args=(snapshots, use_rag), daemon=True).start() + + def _analyze_async(self, snapshots, use_rag: bool) -> None: + try: + transitions = [] + for snap in snapshots: + transitions.extend( + analyze_probe_path( + snap["path"], + self.flow_mesh, + sample_every=5, + field_mags=snap["field_mags"] if len(snap["field_mags"]) == len(snap["path"]) else None, + entropy_sampler=self.sampler_mean if self.ENT is not None else None, + entropy_field=self.ENT, + ) + ) + if not transitions: + self.gpt_pending = "No brain regions detected along probe path." + return + self.gpt_pending = analyze_with_gpt(transitions, use_rag=use_rag, model=self.args.model, debug=False) + except Exception as exc: + self.gpt_pending = f"[GPT ERROR] {exc}" + + @property + def status(self) -> str: + mesh_count = len(self.flow_mesh.get_all_region_keys()) if self.flow_mesh else 0 + return ( + f"{self.field_mode} | {self.colour_mode} | " + f"{self.Np:,} particles | {mesh_count} meshes | " + f"{'paused' if self.paused else 'running'}" + ) + + +def build_arg_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="MindVisualizer Trame live server") + parser.add_argument("--host", default="0.0.0.0") + parser.add_argument("--port", type=int, default=int(os.environ.get("PORT", "7860"))) + parser.add_argument("--meta", type=Path, default=DEFAULT_META) + parser.add_argument("--oos", type=Path, default=DEFAULT_OOS) + parser.add_argument("--fps", type=int, default=int(os.environ.get("MINDVIS_FPS", "18"))) + parser.add_argument("--stride", type=int, default=3) + parser.add_argument("--dt", type=float, default=1.0) + parser.add_argument("--max-step-frac", type=float, default=0.01) + parser.add_argument("--speed-scale", type=float, default=1.0) + parser.add_argument("--margin", type=float, default=0.0) + parser.add_argument("--respawn-jitter", type=float, default=0.015) + parser.add_argument("--oos-seed-limit", type=int, default=50000) + parser.add_argument("--oos-fill-count", type=int, default=10000) + parser.add_argument("--overlap-frac", type=float, default=0.01) + parser.add_argument("--overlap-jitter", type=float, default=0.002) + parser.add_argument("--overlap-limit", type=int, default=200000) + parser.add_argument("--no-far-from-oos", action="store_true") + parser.add_argument("--oos-dist-grid", type=int, default=96) + parser.add_argument("--oos-dist-thresh-frac", type=float, default=0.009) + parser.add_argument("--oos-dist-gamma", type=float, default=3.0) + parser.add_argument("--oos-death-boost", type=float, default=8.0) + parser.add_argument("--multi-probe", type=int, default=1) + parser.add_argument("--branching", action="store_true") + parser.add_argument("--window-size", type=int, nargs=2, default=[1320, 820]) + parser.add_argument("--model", default=os.environ.get("OPENAI_MODEL", "gpt-5.4-mini")) + parser.add_argument("--rag", action="store_true") + return parser + + +def main() -> None: + ensure_ssl() + args = build_arg_parser().parse_args() + + from trame.app import get_server + from trame.ui.vuetify3 import SinglePageLayout + from trame.widgets import html, vtk as vtk_widgets, vuetify3 + + app = LiveMindVisualizer(args) + server = get_server(client_type="vue3") + state, ctrl = server.state, server.controller + state.trame__title = "MindVisualizer Live" + state.status = app.status + + def refresh_status() -> None: + state.status = app.status + state.flush() + + def update_view() -> None: + if hasattr(ctrl, "view_update"): + ctrl.view_update() + refresh_status() + + @ctrl.add("toggle_pause") + def toggle_pause(**_): + app.paused = not app.paused + update_view() + + @ctrl.add("cycle_field") + def cycle_field(**_): + app.cycle_field() + update_view() + + @ctrl.add("cycle_colour") + def cycle_colour(**_): + app.cycle_colour() + update_view() + + @ctrl.add("cycle_cmap") + def cycle_cmap(**_): + app.cmap_mode = "RAINBOW" if app.cmap_mode == "TURBO" else "TURBO" + update_view() + + @ctrl.add("mesh_prev") + def mesh_prev(**_): + if app.flow_mesh: + app.flow_mesh.cycle(-1) + update_view() + + @ctrl.add("mesh_next") + def mesh_next(**_): + if app.flow_mesh: + app.flow_mesh.cycle(+1) + update_view() + + @ctrl.add("mesh_hide") + def mesh_hide(**_): + if app.flow_mesh: + app.flow_mesh.hide() + update_view() + + @ctrl.add("mesh_alpha_down") + def mesh_alpha_down(**_): + if app.flow_mesh: + app.flow_mesh.set_opacity(1.0 / 1.25) + update_view() + + @ctrl.add("mesh_alpha_up") + def mesh_alpha_up(**_): + if app.flow_mesh: + app.flow_mesh.set_opacity(1.25) + update_view() + + @ctrl.add("toggle_oos") + def toggle_oos(**_): + if app.o_actor is not None: + if app.oos_visible: + app.ren.RemoveActor(app.o_actor) + else: + app.ren.AddActor(app.o_actor) + app.oos_visible = not app.oos_visible + update_view() + + @ctrl.add("place_probe") + def place_probe(**_): + app.place_probe_center() + update_view() + + @ctrl.add("analyze_probe") + def analyze_probe(**_): + app.analyze_probe(use_rag=args.rag) + update_view() + + @ctrl.add("clear_probe") + def clear_probe(**_): + app.probe_sys.clear() + app.text_overlay.clear_log() + app.text_overlay.hide_gpt() + update_view() + + @ctrl.add("reset_camera") + def reset_camera(**_): + if hasattr(ctrl, "view_reset_camera"): + ctrl.view_reset_camera() + update_view() + + async def animation_loop(): + delay = 1.0 / max(1, int(args.fps)) + while True: + app.step() + if hasattr(ctrl, "view_update"): + ctrl.view_update() + state.status = app.status + state.flush() + await asyncio.sleep(delay) + + ctrl.on_server_ready.add(lambda **_: asyncio.create_task(animation_loop())) + + with SinglePageLayout(server) as layout: + layout.title.set_text("MindVisualizer Live") + with layout.toolbar: + vuetify3.VBtn("Pause", click=ctrl.toggle_pause, density="compact", variant="tonal") + vuetify3.VBtn("Field", click=ctrl.cycle_field, density="compact", variant="tonal") + vuetify3.VBtn("Colour", click=ctrl.cycle_colour, density="compact", variant="tonal") + vuetify3.VBtn("Cmap", click=ctrl.cycle_cmap, density="compact", variant="tonal") + vuetify3.VDivider(vertical=True, classes="mx-2") + vuetify3.VBtn("Mesh -", click=ctrl.mesh_prev, density="compact", variant="tonal") + vuetify3.VBtn("Mesh +", click=ctrl.mesh_next, density="compact", variant="tonal") + vuetify3.VBtn("Hide", click=ctrl.mesh_hide, density="compact", variant="tonal") + vuetify3.VBtn("Alpha -", click=ctrl.mesh_alpha_down, density="compact", variant="tonal") + vuetify3.VBtn("Alpha +", click=ctrl.mesh_alpha_up, density="compact", variant="tonal") + vuetify3.VDivider(vertical=True, classes="mx-2") + vuetify3.VBtn("OOS", click=ctrl.toggle_oos, density="compact", variant="tonal") + vuetify3.VBtn("Probe", click=ctrl.place_probe, density="compact", variant="tonal") + vuetify3.VBtn("Analyze", click=ctrl.analyze_probe, density="compact", variant="tonal") + vuetify3.VBtn("Clear", click=ctrl.clear_probe, density="compact", variant="tonal") + vuetify3.VSpacer() + html.Div("{{ status }}", classes="text-caption text-medium-emphasis") + + with layout.content: + with vuetify3.VContainer(fluid=True, classes="pa-0 fill-height", style="background: #000;"): + view = vtk_widgets.VtkRemoteView( + app.win, + ref="view", + interactive_ratio=0.65, + interactive_quality=55, + ) + ctrl.view_update = view.update + ctrl.view_reset_camera = view.reset_camera + + server.start(host=args.host, port=args.port) + + +if __name__ == "__main__": + main() diff --git a/data/brain_alignment.json b/data/brain_alignment.json new file mode 100644 index 0000000000000000000000000000000000000000..2c7002ec79a5343c9a1b4922b81eae695b759a90 --- /dev/null +++ b/data/brain_alignment.json @@ -0,0 +1,28 @@ +{ + "matrix": [ + [ + -0.03483789662196803, + -0.1654855793300339, + 0.9305766368497694, + -68.4377205759064 + ], + [ + -0.9422190580860553, + -0.06752555868994387, + -0.047281905216520856, + 106.73199000287599 + ], + [ + 0.0747101104018069, + -0.9287770749837014, + -0.16236864674663903, + 115.80117022013123 + ], + [ + 0.0, + 0.0, + 0.0, + 1.0 + ] + ] +} \ No newline at end of file diff --git a/data/extra_parcellation/README.md b/data/extra_parcellation/README.md new file mode 100644 index 0000000000000000000000000000000000000000..ae29dc39e915aaa6cfbb8cb9a6b3f437cd46cf2d --- /dev/null +++ b/data/extra_parcellation/README.md @@ -0,0 +1,53 @@ +# Extra Parcellation Data + +Provides fine-grained subregion labels alongside the primary Allen atlas meshes. + +## Setup (recommended) + +Run the setup script to build the combined atlas automatically: + +```bash +python scripts/setup_extra_parcellation.py +``` + +This downloads and merges three complementary MNI-space atlases via nilearn: + +| Layer | Source | Regions | Role | +|-------|--------|---------|------| +| 1 | Harvard-Oxford Cortical | 48 | Broad cortical coverage | +| 2 | Harvard-Oxford Subcortical | 17 | Thalamus, putamen, caudate, hippocampus, amygdala, etc. | +| 3 | **Julich-Brain** (highest priority) | 62 | Cytoarchitectonic: motor BA4a/4p, somatosensory BA1-3, visual V1-V5, auditory, Broca's, hippocampal subfields | + +**Total: 127 labeled regions**, Julich-Brain labels override coarser Harvard-Oxford +where available. + +Output files (auto-detected by flow mode): +- `combined_atlas.nii.gz` — NIfTI volume (~700KB) +- `combined_atlas_labels.json` — label ID to name map + +## How it works + +The combined atlas is in MNI152 1mm space. The Allen atlas scene space is +approximately MNI-aligned, so probe coordinates can be used directly for lookups. +When the exact voxel is unlabeled, a nearby-search fallback finds the closest +labeled region within a few mm. + +When both the primary Allen mesh and the extra parcellation cover a probe position, +both are highlighted: the Allen mesh in red, the extra parcellation subregion in +orange. The subregion name is included in the LLM context as additional detail. + +## Custom atlas + +You can also provide any NIfTI atlas in MNI space: + +```bash +python examples/flow_probe_example.py \ + --extra-parcellation my_atlas.nii.gz \ + --extra-parcellation-labels my_atlas_labels.json +``` + +## Requirements + +```bash +pip install nilearn nibabel scikit-image +``` diff --git a/data/extra_parcellation/combined_atlas.nii.gz b/data/extra_parcellation/combined_atlas.nii.gz new file mode 100644 index 0000000000000000000000000000000000000000..4b42a04a9e5d9054a2b84716983f1e257022826f --- /dev/null +++ b/data/extra_parcellation/combined_atlas.nii.gz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:68b7dbb0b9949e0e3021d60db9d6f1499615560cba955e0a2f6ab2e41962d25a +size 693799 diff --git a/data/extra_parcellation/combined_atlas_labels.json b/data/extra_parcellation/combined_atlas_labels.json new file mode 100644 index 0000000000000000000000000000000000000000..9cf00f0ce02032a08450b40f44dbd993bd95e0de --- /dev/null +++ b/data/extra_parcellation/combined_atlas_labels.json @@ -0,0 +1,129 @@ +{ + "1": "Frontal Pole", + "2": "Insular Cortex", + "3": "Superior Frontal Gyrus", + "4": "Middle Frontal Gyrus", + "5": "Inferior Frontal Gyrus, pars triangularis", + "6": "Inferior Frontal Gyrus, pars opercularis", + "7": "Precentral Gyrus", + "8": "Temporal Pole", + "9": "Superior Temporal Gyrus, anterior division", + "10": "Superior Temporal Gyrus, posterior division", + "11": "Middle Temporal Gyrus, anterior division", + "12": "Middle Temporal Gyrus, posterior division", + "13": "Middle Temporal Gyrus, temporooccipital part", + "14": "Inferior Temporal Gyrus, anterior division", + "15": "Inferior Temporal Gyrus, posterior division", + "16": "Inferior Temporal Gyrus, temporooccipital part", + "17": "Postcentral Gyrus", + "18": "Superior Parietal Lobule", + "19": "Supramarginal Gyrus, anterior division", + "20": "Supramarginal Gyrus, posterior division", + "21": "Angular Gyrus", + "22": "Lateral Occipital Cortex, superior division", + "23": "Lateral Occipital Cortex, inferior division", + "24": "Intracalcarine Cortex", + "25": "Frontal Medial Cortex", + "26": "Juxtapositional Lobule Cortex (formerly Supplementary Motor Cortex)", + "27": "Subcallosal Cortex", + "28": "Paracingulate Gyrus", + "29": "Cingulate Gyrus, anterior division", + "30": "Cingulate Gyrus, posterior division", + "31": "Precuneous Cortex", + "32": "Cuneal Cortex", + "33": "Frontal Orbital Cortex", + "34": "Parahippocampal Gyrus, anterior division", + "35": "Parahippocampal Gyrus, posterior division", + "36": "Lingual Gyrus", + "37": "Temporal Fusiform Cortex, anterior division", + "38": "Temporal Fusiform Cortex, posterior division", + "39": "Temporal Occipital Fusiform Cortex", + "40": "Occipital Fusiform Gyrus", + "41": "Frontal Opercular Cortex", + "42": "Central Opercular Cortex", + "43": "Parietal Opercular Cortex", + "44": "Planum Polare", + "45": "Heschl's Gyrus (includes H1 and H2)", + "46": "Planum Temporale", + "47": "Supracalcarine Cortex", + "48": "Occipital Pole", + "103": "Left Lateral Ventricle", + "104": "Left Thalamus", + "105": "Left Caudate", + "106": "Left Putamen", + "107": "Left Pallidum", + "108": "Brain-Stem", + "109": "Left Hippocampus", + "110": "Left Amygdala", + "111": "Left Accumbens", + "114": "Right Lateral Ventricle", + "115": "Right Thalamus", + "116": "Right Caudate", + "117": "Right Putamen", + "118": "Right Pallidum", + "119": "Right Hippocampus", + "120": "Right Amygdala", + "121": "Right Accumbens", + "201": "Amygdala_centromedial group", + "202": "Amygdala_laterobasal group", + "203": "Amygdala_superficial group", + "204": "Anterior intra-parietal sulcus hIP1", + "205": "Anterior intra-parietal sulcus hIP2", + "206": "Anterior intra-parietal sulcus hIP3", + "207": "Broca's area BA44", + "208": "Broca's area BA45", + "209": "Hippocampus cornu ammonis", + "210": "Hippocampus dentate gyrus", + "211": "Hippocampus entorhinal cortex", + "212": "Hippocampus hippocampal-amygdaloid transition area", + "213": "Hippocampus subiculum", + "214": "Inferior parietal lobule PF", + "215": "Inferior parietal lobule PFcm", + "216": "Inferior parietal lobule PFm", + "217": "Inferior parietal lobule PFop", + "218": "Inferior parietal lobule PFt", + "219": "Inferior parietal lobule PGp", + "220": "Inferior parietal lobule Pga", + "221": "Insula Id1", + "222": "Insula Ig1", + "223": "Insula Ig2", + "224": "Lateral geniculate body", + "225": "Mamillary body", + "226": "Medial geniculate body", + "227": "Premotor cortex BA6", + "228": "Primary auditory cortex TE1.0", + "229": "Primary auditory cortex TE1.1", + "230": "Primary auditory cortex TE1.2", + "231": "Primary motor cortex BA4a", + "232": "Primary motor cortex BA4p", + "233": "Primary somatosensory cortex BA1", + "234": "Primary somatosensory cortex BA2", + "235": "Primary somatosensory cortex BA3a", + "236": "Primary somatosensory cortex BA3b", + "237": "Secondary somatosensory cortex / Parietal operculum OP1", + "238": "Secondary somatosensory cortex / Parietal operculum OP2", + "239": "Secondary somatosensory cortex / Parietal operculum OP3", + "240": "Secondary somatosensory cortex / Parietal operculum OP4", + "241": "Superior parietal lobule 5Ci", + "242": "Superior parietal lobule 5L", + "243": "Superior parietal lobule 5M", + "244": "Superior parietal lobule 7A", + "245": "Superior parietal lobule 7M", + "246": "Superior parietal lobule 7P", + "247": "Superior parietal lobule 7PC", + "248": "Visual cortex V1 BA17", + "249": "Visual cortex V2 BA18", + "250": "Visual cortex V3V", + "251": "Visual cortex V4", + "252": "Visual cortex V5", + "253": "WM: Acoustic radiation", + "254": "WM: Callosal body", + "255": "WM: Cingulum", + "256": "WM: Corticospinal tract", + "257": "WM: Fornix", + "258": "WM: Inferior occipito-frontal fascicle", + "259": "WM: Optic radiation", + "260": "WM: Superior longitudinal fascicle", + "261": "WM: Superior occipito-frontal fascicle", + "262": "WM: Uncinate fascicle" +} \ No newline at end of file diff --git a/data/label_grid_cache.keys.json b/data/label_grid_cache.keys.json new file mode 100644 index 0000000000000000000000000000000000000000..95bdfe83711a5d3809cb9f88379c3740d1848180 --- /dev/null +++ b/data/label_grid_cache.keys.json @@ -0,0 +1 @@ +["", "10159", "10292", "10306", "10307", "10308", "10311", "10313", "10327", "10329", "10330", "10331", "10332", "10333", "10334", "10335", "10336", "10337", "10338", "10339", "10342", "10343", "10344", "10346", "10349", "10361", "10383", "10384", "10389", "10390", "10465", "10466", "10467", "10595", "10596", "10597", "10598", "10599", "10600", "10602", "10648", "10649", "10650", "10651", "10653", "10654", "10655", "10656", "10657", "10658", "10659", "10660", "10661", "10662", "10669", "12114", "12115", "12116", "12117", "12118", "12119", "12121", "12122", "12123", "12124", "12125", "12126", "12127", "12132", "12133", "12136", "12137", "12138", "12140", "12142", "12143", "12144", "12145", "12146", "12147", "12148", "12149", "12150", "12151", "12152", "12153", "12154", "12155", "12156", "12157", "12158", "12159", "12160", "12162", "12163", "12164", "12165", "12166", "12170", "12171", "12173", "12174", "12176", "12177", "12178", "12179", "12181", "12195", "12291", "12292", "12305", "12330", "12354", "12369", "12384", "12390", "12405", "12416", "12535", "12538", "12805", "12826", "13229", "13230", "146034872", "146034876", "146034884", "146034888", "266441657"] \ No newline at end of file diff --git a/data/label_grid_cache.npz b/data/label_grid_cache.npz new file mode 100644 index 0000000000000000000000000000000000000000..81d7797137531b49f3103f7c59b52e637ce43a4c --- /dev/null +++ b/data/label_grid_cache.npz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ab71af20799b4cefb9986791337fcb609105b4653141cde9285d486cbfaa4791 +size 101223 diff --git a/data/mdn/mdn_particles_rdcim_teacher_edge_hq_grid125_entropy.bin b/data/mdn/mdn_particles_rdcim_teacher_edge_hq_grid125_entropy.bin new file mode 100644 index 0000000000000000000000000000000000000000..4bb120fcc4dec11a91f64d64041c4c0e28248c2a --- /dev/null +++ b/data/mdn/mdn_particles_rdcim_teacher_edge_hq_grid125_entropy.bin @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2dc61d9b05f915dad3e8ade5e5d9e5f1df27983cbeaf34c0232010ab7e0008d7 +size 7812500 diff --git a/data/mdn/mdn_particles_rdcim_teacher_edge_hq_grid125_mean_xyz3.bin b/data/mdn/mdn_particles_rdcim_teacher_edge_hq_grid125_mean_xyz3.bin new file mode 100644 index 0000000000000000000000000000000000000000..f32b4975fa4f7a6e5de97db599322c1006542788 --- /dev/null +++ b/data/mdn/mdn_particles_rdcim_teacher_edge_hq_grid125_mean_xyz3.bin @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:34525f6bf4a68ac06f87801aa180b57cfea10c8c8559ea67d23d66ea54865109 +size 23437500 diff --git a/data/mdn/mdn_particles_rdcim_teacher_edge_hq_grid125_meta.json b/data/mdn/mdn_particles_rdcim_teacher_edge_hq_grid125_meta.json new file mode 100644 index 0000000000000000000000000000000000000000..97961ae0e0f3bed6e09569f538d18c6c9dbb9d61 --- /dev/null +++ b/data/mdn/mdn_particles_rdcim_teacher_edge_hq_grid125_meta.json @@ -0,0 +1 @@ +{"grid":125,"axis_min":[-69.5,-104.5625,-54.25],"axis_max":[70.0625,76.09375,81.8125],"files":{"mean_xyz3":"mdn_particles_rdcim_teacher_edge_hq_grid125_mean_xyz3.bin","pi":"mdn_particles_rdcim_teacher_edge_hq_grid125_pi_xyz6.bin","entropy":"mdn_particles_rdcim_teacher_edge_hq_grid125_entropy.bin","mu1_xyz3":"mdn_particles_rdcim_teacher_edge_hq_grid125_mu1_xyz3.bin","mu2_xyz3":"mdn_particles_rdcim_teacher_edge_hq_grid125_mu2_xyz3.bin","mu3_xyz3":"mdn_particles_rdcim_teacher_edge_hq_grid125_mu3_xyz3.bin","mu4_xyz3":"mdn_particles_rdcim_teacher_edge_hq_grid125_mu4_xyz3.bin","mu5_xyz3":"mdn_particles_rdcim_teacher_edge_hq_grid125_mu5_xyz3.bin","mu6_xyz3":"mdn_particles_rdcim_teacher_edge_hq_grid125_mu6_xyz3.bin"},"dtype":"float32","layout":"C-order (x,y,z,3) for *_xyz3; (x,y,z,K) for pi_xyzK; (x,y,z) for entropy","endianness":"little","mixture_mean_field":true,"model":{"quality":"hq","components":6},"mdn_source":"particles/rdcim_teacher_edge","bounds_mode":"full_brain","mode":"field","notes":"Trained on teacher segments (XYZ→ΔS) with optional rDCM conditioning and ROI embeddings","training_points_npy":"mdn_particles_rdcim_teacher_edge_hq_training_points.npy"} \ No newline at end of file diff --git a/data/mdn/mdn_particles_rdcim_teacher_edge_hq_grid125_mu1_xyz3.bin b/data/mdn/mdn_particles_rdcim_teacher_edge_hq_grid125_mu1_xyz3.bin new file mode 100644 index 0000000000000000000000000000000000000000..684974e8f081ee759909379a2d01d9517a2dca80 --- /dev/null +++ b/data/mdn/mdn_particles_rdcim_teacher_edge_hq_grid125_mu1_xyz3.bin @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:44c45d1ccd4c2afb936d7a4622493b1d363df9664f6ce53cb26cd5bd892b79e8 +size 23437500 diff --git a/data/mdn/mdn_particles_rdcim_teacher_edge_hq_grid125_mu2_xyz3.bin b/data/mdn/mdn_particles_rdcim_teacher_edge_hq_grid125_mu2_xyz3.bin new file mode 100644 index 0000000000000000000000000000000000000000..1dea321b1e46911dd57c3fd69aef2d1430197677 --- /dev/null +++ b/data/mdn/mdn_particles_rdcim_teacher_edge_hq_grid125_mu2_xyz3.bin @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2f1b9505a5ae611528425d57b09387e35dd921c20377a3b232a4267133b19baf +size 23437500 diff --git a/data/mdn/mdn_particles_rdcim_teacher_edge_hq_grid125_mu3_xyz3.bin b/data/mdn/mdn_particles_rdcim_teacher_edge_hq_grid125_mu3_xyz3.bin new file mode 100644 index 0000000000000000000000000000000000000000..a1a3afc5a3ad8d8100f1c3a24b2d998308b0b6f9 --- /dev/null +++ b/data/mdn/mdn_particles_rdcim_teacher_edge_hq_grid125_mu3_xyz3.bin @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:53b0999649dfccf31f8726695c4d5a738009f408389a16954144be89d2a58c87 +size 23437500 diff --git a/data/mdn/mdn_particles_rdcim_teacher_edge_hq_grid125_mu4_xyz3.bin b/data/mdn/mdn_particles_rdcim_teacher_edge_hq_grid125_mu4_xyz3.bin new file mode 100644 index 0000000000000000000000000000000000000000..b79f3a693e60f108df082e07cd5bb0bd1494b72d --- /dev/null +++ b/data/mdn/mdn_particles_rdcim_teacher_edge_hq_grid125_mu4_xyz3.bin @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:afd81e6a49823e2ddb42f8e4256f2bf58f268f5fbf83d95fbf0dc0f0eedef559 +size 23437500 diff --git a/data/mdn/mdn_particles_rdcim_teacher_edge_hq_grid125_mu5_xyz3.bin b/data/mdn/mdn_particles_rdcim_teacher_edge_hq_grid125_mu5_xyz3.bin new file mode 100644 index 0000000000000000000000000000000000000000..be04e37476cd77933b0ab3d3c9074c1601daae9f --- /dev/null +++ b/data/mdn/mdn_particles_rdcim_teacher_edge_hq_grid125_mu5_xyz3.bin @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:68c1f5eceb934435f58ea77c3b623abb114256cfc23affaa2dba1a202139a4aa +size 23437500 diff --git a/data/mdn/mdn_particles_rdcim_teacher_edge_hq_grid125_mu6_xyz3.bin b/data/mdn/mdn_particles_rdcim_teacher_edge_hq_grid125_mu6_xyz3.bin new file mode 100644 index 0000000000000000000000000000000000000000..30fbcc140d88337d398d195d4b32e3f98edd7861 --- /dev/null +++ b/data/mdn/mdn_particles_rdcim_teacher_edge_hq_grid125_mu6_xyz3.bin @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9827f9f7a923994c84e1a44773fc2fe88dd91fd7905e718083dc752d24a31f4d +size 23437500 diff --git a/data/mdn/mdn_particles_rdcim_teacher_edge_hq_grid125_pi_xyz6.bin b/data/mdn/mdn_particles_rdcim_teacher_edge_hq_grid125_pi_xyz6.bin new file mode 100644 index 0000000000000000000000000000000000000000..7eece50b7104fcce5a585d64bcf2e21d804061aa --- /dev/null +++ b/data/mdn/mdn_particles_rdcim_teacher_edge_hq_grid125_pi_xyz6.bin @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ffb8a88b783e3a9aefeb8b7c14cd925769fcd3b4ef7d9ca029e4a8f71cd25265 +size 46875000 diff --git a/data/mdn/mdn_particles_rdcim_teacher_edge_hq_training_points.npy b/data/mdn/mdn_particles_rdcim_teacher_edge_hq_training_points.npy new file mode 100644 index 0000000000000000000000000000000000000000..a837e666681a36038bfc81eb04e577c830bdfa1a --- /dev/null +++ b/data/mdn/mdn_particles_rdcim_teacher_edge_hq_training_points.npy @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0f4ac819327e772ab01ed85b81150d2a28111df1ba4ebd9b2b3af200a011849b +size 14400128 diff --git a/data/meshes/.keep b/data/meshes/.keep new file mode 100644 index 0000000000000000000000000000000000000000..fcd62251373eb558340d3d730fcbca76622e8c8b --- /dev/null +++ b/data/meshes/.keep @@ -0,0 +1 @@ +placeholder so code paths that check data/meshes exist keep working; actual OBJ meshes are in data/meshes_obj. diff --git a/data/meshes_obj/10153.obj b/data/meshes_obj/10153.obj new file mode 100644 index 0000000000000000000000000000000000000000..26bfe872647b218e9878389e87cfca6e22d8213c --- /dev/null +++ b/data/meshes_obj/10153.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:03245194c13e6081c41f81bfafb018f9aa819af57642f1729d6830db236912cb +size 9316827 diff --git a/data/meshes_obj/10154.obj b/data/meshes_obj/10154.obj new file mode 100644 index 0000000000000000000000000000000000000000..e3cc669e811373f068dcb9fec9b9c6312ba3347a --- /dev/null +++ b/data/meshes_obj/10154.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0026eafd072ba11e02f3f4eab7f768ebcf89f32f5a0144fa38d8774576a1baff +size 13523828 diff --git a/data/meshes_obj/10155.obj b/data/meshes_obj/10155.obj new file mode 100644 index 0000000000000000000000000000000000000000..9df5a2952d1d1601daf7da52e2f17759a8e96a4c --- /dev/null +++ b/data/meshes_obj/10155.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:89e07d4dc266bd9c69404da5b1ce8a1ea23dd2a41560e30f350b3c49a23e4c65 +size 13523828 diff --git a/data/meshes_obj/10156.obj b/data/meshes_obj/10156.obj new file mode 100644 index 0000000000000000000000000000000000000000..8b32bfd79ac58bf06c6f30cbaaccec500b31ab49 --- /dev/null +++ b/data/meshes_obj/10156.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8dd907bcc3ed5f654395b9b2bec702f2f00355d6e7a9d208e18e01766f2782eb +size 12090622 diff --git a/data/meshes_obj/10157.obj b/data/meshes_obj/10157.obj new file mode 100644 index 0000000000000000000000000000000000000000..35bc3216a4caa2df3ee13c7eb6682cfd862beae2 --- /dev/null +++ b/data/meshes_obj/10157.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c1432ccc092ed2e7b1495e506a11179420fcfb0919519824bb35329ee8171002 +size 3291242 diff --git a/data/meshes_obj/10158.obj b/data/meshes_obj/10158.obj new file mode 100644 index 0000000000000000000000000000000000000000..0ac2b4d5104ce194d374aee23514fd52c9dca9af --- /dev/null +++ b/data/meshes_obj/10158.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:be09e3ec39b5cacb24af0e9f7d8e69086fd35853899daea7729bf7f048bb5c6d +size 1992516 diff --git a/data/meshes_obj/10159.obj b/data/meshes_obj/10159.obj new file mode 100644 index 0000000000000000000000000000000000000000..ef161b112302f52b7e1a78acf4231e0833cee691 --- /dev/null +++ b/data/meshes_obj/10159.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:aa7c48344b0af6f41f069450f319e9c360ef8b45fe8f36dded24cb45e7ba0aef +size 149484 diff --git a/data/meshes_obj/10292.obj b/data/meshes_obj/10292.obj new file mode 100644 index 0000000000000000000000000000000000000000..e858ab1dd18c5a9ef70cec3e0e4d7464f6620ba6 --- /dev/null +++ b/data/meshes_obj/10292.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2c4b4771c184c4512cfadef75b152e0bcbe9d816a62102d7b93349c967d0d955 +size 112083 diff --git a/data/meshes_obj/10306.obj b/data/meshes_obj/10306.obj new file mode 100644 index 0000000000000000000000000000000000000000..f93a4707d85ea3fffbeddfcc5be9c0e478eae356 --- /dev/null +++ b/data/meshes_obj/10306.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:aef8de5471788784415bdd77b44e5c532cabc79f5fc3f3c4be37e9dd6c5222e8 +size 112083 diff --git a/data/meshes_obj/10307.obj b/data/meshes_obj/10307.obj new file mode 100644 index 0000000000000000000000000000000000000000..d0e933316a48021b20e86c93af9d756e21e5a999 --- /dev/null +++ b/data/meshes_obj/10307.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:25a071fd3a955681fcd64f6d28fd917c921e9558b792f622649f93ebace11aa6 +size 19205 diff --git a/data/meshes_obj/10308.obj b/data/meshes_obj/10308.obj new file mode 100644 index 0000000000000000000000000000000000000000..d77c99104bbcdab0291e3ed1658aec3eb662f817 --- /dev/null +++ b/data/meshes_obj/10308.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:83c6c01f3d02a6980403f340692930dbe47339c03784b4c6a41d6cc8bcf45dd2 +size 28542 diff --git a/data/meshes_obj/10311.obj b/data/meshes_obj/10311.obj new file mode 100644 index 0000000000000000000000000000000000000000..c315f2ad657a194523489c2ba002c95e506171bf --- /dev/null +++ b/data/meshes_obj/10311.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6e94088b6a00b31e0077ecb75f28d21621f8b8f4ae1a033b76756713b66737fe +size 57711 diff --git a/data/meshes_obj/10313.obj b/data/meshes_obj/10313.obj new file mode 100644 index 0000000000000000000000000000000000000000..de618be9c90db7ce3ec7ae9ce7f42cdf6986846a --- /dev/null +++ b/data/meshes_obj/10313.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:22a69c74d7c982a8372ac63c49df436bf8bc0fc1edb401710c4939c85fd9866f +size 48263 diff --git a/data/meshes_obj/10327.obj b/data/meshes_obj/10327.obj new file mode 100644 index 0000000000000000000000000000000000000000..0928bd1d39509e583577a737434e03af51b5432a --- /dev/null +++ b/data/meshes_obj/10327.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:61602d7e56749544c6773467d7b8af22aa7788de7640de8c0b18e6fc9630239a +size 48263 diff --git a/data/meshes_obj/10329.obj b/data/meshes_obj/10329.obj new file mode 100644 index 0000000000000000000000000000000000000000..4f7643766bc7f33df97d002ec3a1dbc464fe6b22 --- /dev/null +++ b/data/meshes_obj/10329.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f145efd0531e791a5f2059f89a4c649a83b12ffee4bd06648569cc36396bbd3c +size 17345 diff --git a/data/meshes_obj/10330.obj b/data/meshes_obj/10330.obj new file mode 100644 index 0000000000000000000000000000000000000000..ba7e19a477d8087b255fd8554d41ab4177d1e135 --- /dev/null +++ b/data/meshes_obj/10330.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8d96c21416298bac87134b1f41f762ac122854cd66a93f53bd97a8d833f7b99c +size 36168 diff --git a/data/meshes_obj/10331.obj b/data/meshes_obj/10331.obj new file mode 100644 index 0000000000000000000000000000000000000000..aaeda3097344cfa25f90ee032f8ef810ae74f4e8 --- /dev/null +++ b/data/meshes_obj/10331.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1e092fe926797259b4568d792ea0439daf36297ab5c9946a689892abe4c3b9aa +size 1853173 diff --git a/data/meshes_obj/10332.obj b/data/meshes_obj/10332.obj new file mode 100644 index 0000000000000000000000000000000000000000..0e18e9a92710136c7a6e2568fb7e2b649d80598a --- /dev/null +++ b/data/meshes_obj/10332.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c5030b54f80cad2316a5cc57db2649ac44e38d31d61677bda1710ebb29e1418f +size 1027691 diff --git a/data/meshes_obj/10333.obj b/data/meshes_obj/10333.obj new file mode 100644 index 0000000000000000000000000000000000000000..36ad701b5de1f2593370bc97d037f112d81c3c61 --- /dev/null +++ b/data/meshes_obj/10333.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:df7e6a6ae97116b644f5090984b56ddcf5da574b5387c22bb8c554f9fff089ce +size 949235 diff --git a/data/meshes_obj/10334.obj b/data/meshes_obj/10334.obj new file mode 100644 index 0000000000000000000000000000000000000000..4d6681422de10e0fab7364d8efab75ee5f3e6f40 --- /dev/null +++ b/data/meshes_obj/10334.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a16d8d4d1fcb2cc0ac4be610e5f200c6efd910b3de982492432f563c39f6c2b9 +size 498360 diff --git a/data/meshes_obj/10335.obj b/data/meshes_obj/10335.obj new file mode 100644 index 0000000000000000000000000000000000000000..02339fd31653adcf58ebcc2f1e4cf609f4e59d96 --- /dev/null +++ b/data/meshes_obj/10335.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6f4c64f991bb22248463cf2415ab33c6cf89235bb85fa26425672958c6362708 +size 225016 diff --git a/data/meshes_obj/10336.obj b/data/meshes_obj/10336.obj new file mode 100644 index 0000000000000000000000000000000000000000..9459de8daa095face827d07774e99bffb92b3516 --- /dev/null +++ b/data/meshes_obj/10336.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:88b46eb320fe5c91aa1da562277d436caaabace45b0547a1b0d71e13cfa86d81 +size 157144 diff --git a/data/meshes_obj/10337.obj b/data/meshes_obj/10337.obj new file mode 100644 index 0000000000000000000000000000000000000000..b0479b10eb3b0b8851ab48023772acc459380b23 --- /dev/null +++ b/data/meshes_obj/10337.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f60b0f68b013cfca7c50fc2afd63db26e64098d8e6476990d7cbc339613d68eb +size 162134 diff --git a/data/meshes_obj/10338.obj b/data/meshes_obj/10338.obj new file mode 100644 index 0000000000000000000000000000000000000000..a7f302bcc9d63282971bc673aa5421e00911db16 --- /dev/null +++ b/data/meshes_obj/10338.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0a913a6bd57ddc20209c2a7d0c873705c234eb0f116ae0dfbb7fa88a7074d017 +size 374785 diff --git a/data/meshes_obj/10339.obj b/data/meshes_obj/10339.obj new file mode 100644 index 0000000000000000000000000000000000000000..bad4c44e12f9400762a222849edd45e45260bf57 --- /dev/null +++ b/data/meshes_obj/10339.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0521f9f55fd1c10d2d9560fff80cb2385a6a9fab003bea1c524e4db1a154bcbb +size 74905 diff --git a/data/meshes_obj/10342.obj b/data/meshes_obj/10342.obj new file mode 100644 index 0000000000000000000000000000000000000000..1c4ae9ca469b1db4f7eab0971f57bffa89b7cb23 --- /dev/null +++ b/data/meshes_obj/10342.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0864087632dbee936a66dd7d0ab48f208db7fc57118bdef6a1b37e0dbe38b162 +size 142593 diff --git a/data/meshes_obj/10343.obj b/data/meshes_obj/10343.obj new file mode 100644 index 0000000000000000000000000000000000000000..6f4a1ae6231e7a0c67ce271cc8d855b2a69463d7 --- /dev/null +++ b/data/meshes_obj/10343.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8c02b9faf662b97b9275ec64a0da4818c28d5c60f0c7299b0f02212b1f0cf2ea +size 121130 diff --git a/data/meshes_obj/10344.obj b/data/meshes_obj/10344.obj new file mode 100644 index 0000000000000000000000000000000000000000..349eb44f72b0f961f5181922ab476600feff3025 --- /dev/null +++ b/data/meshes_obj/10344.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bf6753d2b9f64cd0f2d6aa8202d6bcdddea7faf10fc4daf6e7c30892c89a2c45 +size 53546 diff --git a/data/meshes_obj/10346.obj b/data/meshes_obj/10346.obj new file mode 100644 index 0000000000000000000000000000000000000000..fc3bb914fc6c722f8b64aec8d3c3cd959a85a0f1 --- /dev/null +++ b/data/meshes_obj/10346.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2af0bc2a2c14637a896b13b5ccee911dd9f0d214353ed87aaebbe4b686cdc3d0 +size 396936 diff --git a/data/meshes_obj/10349.obj b/data/meshes_obj/10349.obj new file mode 100644 index 0000000000000000000000000000000000000000..fecad40334193cff612d106a540ad1d18ffe777c --- /dev/null +++ b/data/meshes_obj/10349.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ce207044a37d2cbafbcbc36b3469ff44094f16caae936efe64cbbed076f41c05 +size 106623 diff --git a/data/meshes_obj/10361.obj b/data/meshes_obj/10361.obj new file mode 100644 index 0000000000000000000000000000000000000000..50a89b93487c93022b50f45851b9036757c06a1b --- /dev/null +++ b/data/meshes_obj/10361.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cb310fac98101e639c504fc751aac087a8d8b8cbded5d5f4e5c0e3764ab03396 +size 374607 diff --git a/data/meshes_obj/10383.obj b/data/meshes_obj/10383.obj new file mode 100644 index 0000000000000000000000000000000000000000..f4a8e181a92266b2f731a9750597c421d3089c6b --- /dev/null +++ b/data/meshes_obj/10383.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:def4c08531f630c8d1f3bfa6ed17cab28b19369edba83d628200a36056c91ce2 +size 116231 diff --git a/data/meshes_obj/10384.obj b/data/meshes_obj/10384.obj new file mode 100644 index 0000000000000000000000000000000000000000..082e6edadeb22de28567316f5ae4f0c50433b356 --- /dev/null +++ b/data/meshes_obj/10384.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3acf7f9fdad0d138646f5c944ed1606517cad1e4cb39e1abb06ad074b7df2393 +size 116231 diff --git a/data/meshes_obj/10389.obj b/data/meshes_obj/10389.obj new file mode 100644 index 0000000000000000000000000000000000000000..4ccecd6399fd67400eaf7fba6544e84208661ea9 --- /dev/null +++ b/data/meshes_obj/10389.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b2959f8557ff9349be5db4c7ed5f7675a8278da8814460ec8e47cc8353700230 +size 1269437 diff --git a/data/meshes_obj/10390.obj b/data/meshes_obj/10390.obj new file mode 100644 index 0000000000000000000000000000000000000000..c74cf52b9a9f5b114ff8e766aace44c600b95850 --- /dev/null +++ b/data/meshes_obj/10390.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c387592d26831c252acce15809e6729ba0a0acfd82d99d7cfa214db42fb89909 +size 1211800 diff --git a/data/meshes_obj/10465.obj b/data/meshes_obj/10465.obj new file mode 100644 index 0000000000000000000000000000000000000000..a58d00461f91af9bfe7507b0f28c945e6f7cb967 --- /dev/null +++ b/data/meshes_obj/10465.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:38506f9126d0e884f429b62d9c4328fe2247ed7d54478c2c1b7c397accf0ac3f +size 29976 diff --git a/data/meshes_obj/10466.obj b/data/meshes_obj/10466.obj new file mode 100644 index 0000000000000000000000000000000000000000..fb6f83f25acd3785ac28e799a7995e066f4de49d --- /dev/null +++ b/data/meshes_obj/10466.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ddc40d16f32d80f832e4923b2b8367e19f5ed0fba996622d02c377514cfe0d00 +size 29976 diff --git a/data/meshes_obj/10467.obj b/data/meshes_obj/10467.obj new file mode 100644 index 0000000000000000000000000000000000000000..04883c17d4107d21aee7986d064ead54768fc3fd --- /dev/null +++ b/data/meshes_obj/10467.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f75904e067dcb495977417495089f4e8097255b0c97c52fdf02ea90aee548ed2 +size 26647 diff --git a/data/meshes_obj/10557.obj b/data/meshes_obj/10557.obj new file mode 100644 index 0000000000000000000000000000000000000000..97cfd19fa95e118cbeec4a0bba53a45480d2fd7c --- /dev/null +++ b/data/meshes_obj/10557.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:585bf50b033a56b9706d29654f18a50e125c3a4209b0ad24cc70734e7416f565 +size 21306378 diff --git a/data/meshes_obj/10595.obj b/data/meshes_obj/10595.obj new file mode 100644 index 0000000000000000000000000000000000000000..a8e59114e16d967dfb9f05ad9a75c9574d2f9ce3 --- /dev/null +++ b/data/meshes_obj/10595.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2c6d1d7ddc793b3e280be78df04fee6bbb34ef62490fc25d96b96810fcbf0a78 +size 1207882 diff --git a/data/meshes_obj/10596.obj b/data/meshes_obj/10596.obj new file mode 100644 index 0000000000000000000000000000000000000000..35e75c463098c754bc9aecaf472d7e55314ad440 --- /dev/null +++ b/data/meshes_obj/10596.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:975b8bfd36587af81110e4a935263b3280878d6d4be90af2436fd62e5c51b14e +size 1135648 diff --git a/data/meshes_obj/10597.obj b/data/meshes_obj/10597.obj new file mode 100644 index 0000000000000000000000000000000000000000..fd7daab9a093ea371b98a877c97179e39e89b422 --- /dev/null +++ b/data/meshes_obj/10597.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d8a197cd5d518213c1de446dced561e29dcaecb630682e22f0db9a71ce43fba8 +size 363233 diff --git a/data/meshes_obj/10598.obj b/data/meshes_obj/10598.obj new file mode 100644 index 0000000000000000000000000000000000000000..e3c9d92d0f1eb9dbafc3301a62b544345d5a1237 --- /dev/null +++ b/data/meshes_obj/10598.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3969bed0ece09fa26650bd74357405de379ea48f5716da11af07234e76978976 +size 285736 diff --git a/data/meshes_obj/10599.obj b/data/meshes_obj/10599.obj new file mode 100644 index 0000000000000000000000000000000000000000..3960468805dea68188e3da91f1ee80066e8ec181 --- /dev/null +++ b/data/meshes_obj/10599.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:34d0002dc0f217f8ca002e2784c8ce28295d2ea0f19b98aea4b1a48738e5c825 +size 64287 diff --git a/data/meshes_obj/10600.obj b/data/meshes_obj/10600.obj new file mode 100644 index 0000000000000000000000000000000000000000..bae4af76242defc221301e25c47de23af35edd3f --- /dev/null +++ b/data/meshes_obj/10600.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c3a7515fe8e1590c0232cd2550b4eb7a969cb1f349c961ed433b1166d5156002 +size 261696 diff --git a/data/meshes_obj/10602.obj b/data/meshes_obj/10602.obj new file mode 100644 index 0000000000000000000000000000000000000000..35026ad8c54e2fe43653b163e225dffe7c0fc1fb --- /dev/null +++ b/data/meshes_obj/10602.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:083525f8e6b168d55007de4743b3d967865ca21490423f65d85241b0633e015d +size 60183 diff --git a/data/meshes_obj/10609.obj b/data/meshes_obj/10609.obj new file mode 100644 index 0000000000000000000000000000000000000000..3b5174f39755ea47574910455641a37d5cb0e9f1 --- /dev/null +++ b/data/meshes_obj/10609.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:70d5805170883e21dd580488d02cddbb6e806c0578884374af0802e7c05c6eaf +size 24970539 diff --git a/data/meshes_obj/10648.obj b/data/meshes_obj/10648.obj new file mode 100644 index 0000000000000000000000000000000000000000..370608d94f52c3636ac32c451ae1dbc892519a54 --- /dev/null +++ b/data/meshes_obj/10648.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:167345c94b0e77d00592694f950ed34d4831b485c53cb42f596139a8b41d7646 +size 384366 diff --git a/data/meshes_obj/10649.obj b/data/meshes_obj/10649.obj new file mode 100644 index 0000000000000000000000000000000000000000..ae8a186b01ce7189c34362dba3c2345578316bde --- /dev/null +++ b/data/meshes_obj/10649.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:46ed49218abb1d6abc57cc58ef40570fdc4543cb0865ee427f7b1b5a7f878e14 +size 267287 diff --git a/data/meshes_obj/10650.obj b/data/meshes_obj/10650.obj new file mode 100644 index 0000000000000000000000000000000000000000..896cecc8744ecaeb6885fb36f44cfd8db701ef9c --- /dev/null +++ b/data/meshes_obj/10650.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2b88f2efcaf1a09847f97cd513cb090d3bd1960a3544a3c90f892dc94f8cb773 +size 208885 diff --git a/data/meshes_obj/10651.obj b/data/meshes_obj/10651.obj new file mode 100644 index 0000000000000000000000000000000000000000..71f6839a0b84a467b9ecca4d47d04bfe8fd1a5e2 --- /dev/null +++ b/data/meshes_obj/10651.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5605b9e27eeabc9c587d1491f74597215ed6913148987b60a0f7a53c07043cc1 +size 19216 diff --git a/data/meshes_obj/10653.obj b/data/meshes_obj/10653.obj new file mode 100644 index 0000000000000000000000000000000000000000..9d055cb035244510aac6fb633e5d03b4bdf1de76 --- /dev/null +++ b/data/meshes_obj/10653.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:44f0ac48d5bcb61fcab330100ed2fa82b38c1fe6e78d76d20ef0d0549e0bafe7 +size 2358613 diff --git a/data/meshes_obj/10654.obj b/data/meshes_obj/10654.obj new file mode 100644 index 0000000000000000000000000000000000000000..a8016f5a5a5743904182df39fe659679ad90be83 --- /dev/null +++ b/data/meshes_obj/10654.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2c5d3abc4e700d115d11a234779ae29e356adeb749a7fd6141a7166a60cc875d +size 3544662 diff --git a/data/meshes_obj/10655.obj b/data/meshes_obj/10655.obj new file mode 100644 index 0000000000000000000000000000000000000000..082094b4c3e352408fbeedcb10e16790c95ae30e --- /dev/null +++ b/data/meshes_obj/10655.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ee4ddd140cdedd1bbaa82e3b4318f2ca06ebecac8f5b4b719f93c6fd43a2893b +size 3459944 diff --git a/data/meshes_obj/10656.obj b/data/meshes_obj/10656.obj new file mode 100644 index 0000000000000000000000000000000000000000..4c6474e1c226563728bf5fe14d9b1149286412e0 --- /dev/null +++ b/data/meshes_obj/10656.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:64564caa0e72057bcdac59245b0cfe47f68f8eea7137ce31a89aaddcfc536b57 +size 3102041 diff --git a/data/meshes_obj/10657.obj b/data/meshes_obj/10657.obj new file mode 100644 index 0000000000000000000000000000000000000000..5d6aaa523d4bbfbe1cdf30d53adf94a957b4081c --- /dev/null +++ b/data/meshes_obj/10657.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8bb2ffde5017fb080131381fa2a5649b80e866218e356447045d925d1fc4916b +size 3017443 diff --git a/data/meshes_obj/10658.obj b/data/meshes_obj/10658.obj new file mode 100644 index 0000000000000000000000000000000000000000..7c5da507a9b27058da1a579b816bc3ebe0be854a --- /dev/null +++ b/data/meshes_obj/10658.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7723ef027c48d25f5a7bc0a11894f6172a33b498b051daa5e629242d337328e0 +size 336299 diff --git a/data/meshes_obj/10659.obj b/data/meshes_obj/10659.obj new file mode 100644 index 0000000000000000000000000000000000000000..20cb94eb622b61158a10de276d23b08dd6dd3124 --- /dev/null +++ b/data/meshes_obj/10659.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:78b2f07997d3ca0162b1a9274cd0f193d681eca030136d3dd5a04afca8f26bd5 +size 2860098 diff --git a/data/meshes_obj/10660.obj b/data/meshes_obj/10660.obj new file mode 100644 index 0000000000000000000000000000000000000000..7e4eea40a6cd074f2ad05de04370776d6f61fb00 --- /dev/null +++ b/data/meshes_obj/10660.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f5c88fed97d961850a08737dc5924f663db934948eb8a0df7fd32ac9f3bb0a8a +size 84737 diff --git a/data/meshes_obj/10661.obj b/data/meshes_obj/10661.obj new file mode 100644 index 0000000000000000000000000000000000000000..e90e3f6c413128423e2debebcfca9eccbdcea67d --- /dev/null +++ b/data/meshes_obj/10661.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6825d7c0e678dc6af726dbf4d5702a4693051d58a861820b3e7a046364c4821d +size 320856 diff --git a/data/meshes_obj/10662.obj b/data/meshes_obj/10662.obj new file mode 100644 index 0000000000000000000000000000000000000000..a992deb3a2152df56cb441e13ac92ef729f44ed6 --- /dev/null +++ b/data/meshes_obj/10662.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:622d1dc203cdfbb8a0dba07a975686723e3b5d5c27f5923f5aa364f940586c8f +size 156946 diff --git a/data/meshes_obj/10668.obj b/data/meshes_obj/10668.obj new file mode 100644 index 0000000000000000000000000000000000000000..3224f5438fb836c7db34ebf04c35de8cd24670e2 --- /dev/null +++ b/data/meshes_obj/10668.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1f59c3aa1c7a7342ee1905a8084f8d7e7a9136a1b079588dc1a087f591bda2a4 +size 2710068 diff --git a/data/meshes_obj/10669.obj b/data/meshes_obj/10669.obj new file mode 100644 index 0000000000000000000000000000000000000000..7ed50ea191373f35541f5063da2c6d12084bbf6a --- /dev/null +++ b/data/meshes_obj/10669.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8936c956fe026d7d7c3fd8fde2e4ad860230b6598bf16f0ab9ade5f0696debd0 +size 146189 diff --git a/data/meshes_obj/12112.obj b/data/meshes_obj/12112.obj new file mode 100644 index 0000000000000000000000000000000000000000..d0e0f669224eb159b801496626485bb840b1b434 --- /dev/null +++ b/data/meshes_obj/12112.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:578e35c437c55a3c2cb6040bcb31b7476e268ea1bbe29ff33b78d50a99dd25f2 +size 24978151 diff --git a/data/meshes_obj/12113.obj b/data/meshes_obj/12113.obj new file mode 100644 index 0000000000000000000000000000000000000000..610ba444af6334392ad06a2fc709578d73322043 --- /dev/null +++ b/data/meshes_obj/12113.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:096e7a0d1b188abd87e7a128c1af0e7980b213168b1ea5c1ecdf0f7a2a1fa415 +size 9720765 diff --git a/data/meshes_obj/12114.obj b/data/meshes_obj/12114.obj new file mode 100644 index 0000000000000000000000000000000000000000..c75cffc9b149ee9f7f579ba4e9b3b0e87eef7960 --- /dev/null +++ b/data/meshes_obj/12114.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e0753dff2732096dfc33fdc6d590197664b5962c643b45160b38978276046db4 +size 1874111 diff --git a/data/meshes_obj/12115.obj b/data/meshes_obj/12115.obj new file mode 100644 index 0000000000000000000000000000000000000000..404acaa899145b14ff8bca325fa92758fc80afbc --- /dev/null +++ b/data/meshes_obj/12115.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:de843826a32cb3ebe6e8ec16a72820001e8bcf86b2fc6b3f533d64cb1caa5120 +size 2747516 diff --git a/data/meshes_obj/12116.obj b/data/meshes_obj/12116.obj new file mode 100644 index 0000000000000000000000000000000000000000..569cd3c7f4992158f5ca8c671f43233134a4ac19 --- /dev/null +++ b/data/meshes_obj/12116.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4a4b659f333592ff74aa4605d33949ac06400b7d53840158d14954589280cd2a +size 2238130 diff --git a/data/meshes_obj/12117.obj b/data/meshes_obj/12117.obj new file mode 100644 index 0000000000000000000000000000000000000000..42a1a0ef61732ff52eca3d6a55f1ce9aa3fc536f --- /dev/null +++ b/data/meshes_obj/12117.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7fa9077163c098c180061ade06d363ad21bb5d7667b3137aa1dbb89b11460228 +size 1086983 diff --git a/data/meshes_obj/12118.obj b/data/meshes_obj/12118.obj new file mode 100644 index 0000000000000000000000000000000000000000..60c102d09ff7d34ff750bb8a486fb2de04ae1268 --- /dev/null +++ b/data/meshes_obj/12118.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d799bf2b601e9de14fd0d51777429c1a836532e4e873b51cbaa50fbd8e6f3f28 +size 694317 diff --git a/data/meshes_obj/12119.obj b/data/meshes_obj/12119.obj new file mode 100644 index 0000000000000000000000000000000000000000..f833a0808a7f81f6f2b462eb04802188033b7951 --- /dev/null +++ b/data/meshes_obj/12119.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:43cf3569255b3de29765cd5dcdf7065fd6d85b616b89d3b12a8d602f2ce2a0a0 +size 471273 diff --git a/data/meshes_obj/12121.obj b/data/meshes_obj/12121.obj new file mode 100644 index 0000000000000000000000000000000000000000..76084dab7ec0b0dcd112dfbf338019b54b5e1ea4 --- /dev/null +++ b/data/meshes_obj/12121.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:88c6fca9a0f4a3eb2020a2d2da86fb26cc0a6475899ca183f2b435513809f655 +size 336867 diff --git a/data/meshes_obj/12122.obj b/data/meshes_obj/12122.obj new file mode 100644 index 0000000000000000000000000000000000000000..e417d291a144c809a8a9a68af612508b9fd7e105 --- /dev/null +++ b/data/meshes_obj/12122.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4ca603b9eeca7547b66754c005d87aee480db4cfcfb2b3e3204557610fe5e15b +size 438226 diff --git a/data/meshes_obj/12123.obj b/data/meshes_obj/12123.obj new file mode 100644 index 0000000000000000000000000000000000000000..7ef818ba475aafd191b0b36271fbc4995a6915f7 --- /dev/null +++ b/data/meshes_obj/12123.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f69201b2d75e6456287290d8799fa4d51755d186d87ef21ee055b202a8004236 +size 249258 diff --git a/data/meshes_obj/12124.obj b/data/meshes_obj/12124.obj new file mode 100644 index 0000000000000000000000000000000000000000..0879cc605b1d6b05514a2c75fbe5f0ad026ba73e --- /dev/null +++ b/data/meshes_obj/12124.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f009998041d5b560b4e0cfdaa22f0f1e6d41d1891fcfbd64a742198387072635 +size 355966 diff --git a/data/meshes_obj/12125.obj b/data/meshes_obj/12125.obj new file mode 100644 index 0000000000000000000000000000000000000000..5d0466ce82f46f06f91b98331c75d9a8a97ff249 --- /dev/null +++ b/data/meshes_obj/12125.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ee8afe5307d7c6b3c073120c675f87dea48cd004b8ca34327983fe8b4d2c33de +size 551177 diff --git a/data/meshes_obj/12126.obj b/data/meshes_obj/12126.obj new file mode 100644 index 0000000000000000000000000000000000000000..7a94fa32a403acede52db389d188ae3cda1aa0cf --- /dev/null +++ b/data/meshes_obj/12126.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0a343378c55c7ab0c6e3868e5b9b3f34b8d3e8b8972b000adb238f0fec66f5c6 +size 349423 diff --git a/data/meshes_obj/12127.obj b/data/meshes_obj/12127.obj new file mode 100644 index 0000000000000000000000000000000000000000..9327442fa3748166a691c2f84a383e056c99d21a --- /dev/null +++ b/data/meshes_obj/12127.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8c80da64dddfe9acdc1705ba36098af1196e156f5221d0b496791550a108ac9e +size 235612 diff --git a/data/meshes_obj/12131.obj b/data/meshes_obj/12131.obj new file mode 100644 index 0000000000000000000000000000000000000000..5e0af541d1937b4ffec8d8a9875db67a292aa082 --- /dev/null +++ b/data/meshes_obj/12131.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:38ae817c2dd5691194bb1a9ed5a800bf22bd5d7e1ae125f3192ef70f4cc925eb +size 6385458 diff --git a/data/meshes_obj/12132.obj b/data/meshes_obj/12132.obj new file mode 100644 index 0000000000000000000000000000000000000000..79184a11d94a4d6329120d47ed30518fd69735af --- /dev/null +++ b/data/meshes_obj/12132.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:df4cbf2de2d4a60b662348cb342e7661355a11899f1ca1364e4930cada9d61a8 +size 1771312 diff --git a/data/meshes_obj/12133.obj b/data/meshes_obj/12133.obj new file mode 100644 index 0000000000000000000000000000000000000000..69c6e61c516562a4a94d9cf38d8fae2c948d571b --- /dev/null +++ b/data/meshes_obj/12133.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6ee3aff19c07fd079435bc1e29ddf5be256bab7b36247cd81f04d10b0601b8a8 +size 1340659 diff --git a/data/meshes_obj/12134.obj b/data/meshes_obj/12134.obj new file mode 100644 index 0000000000000000000000000000000000000000..e0f61b328c37794122321cfdd15425fe794e048d --- /dev/null +++ b/data/meshes_obj/12134.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0606da3e272bac8f06a9b2a3807993b3f172eb0ee18c19231ca2338480acd1ce +size 2277035 diff --git a/data/meshes_obj/12135.obj b/data/meshes_obj/12135.obj new file mode 100644 index 0000000000000000000000000000000000000000..a8ff271460b82c00044cf6a32c8087308eb984f3 --- /dev/null +++ b/data/meshes_obj/12135.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e10d73f4c87da6d5b7b6b74b5b43020de3bef75d8c7cee2a6679edcedc751190 +size 1129994 diff --git a/data/meshes_obj/12136.obj b/data/meshes_obj/12136.obj new file mode 100644 index 0000000000000000000000000000000000000000..ae6b39c728ad1975db823bfefc704427b8719602 --- /dev/null +++ b/data/meshes_obj/12136.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:903ed73d986de326c928528581232b5cf22ae4982a3ba30a61cf65e5669e206d +size 1259755 diff --git a/data/meshes_obj/12137.obj b/data/meshes_obj/12137.obj new file mode 100644 index 0000000000000000000000000000000000000000..51dc02792787dc291ef5e420b8718078b034ec00 --- /dev/null +++ b/data/meshes_obj/12137.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7789f0ff6b670010a64444c7934ff9286074578251a12e7c86d2780d4a5c674b +size 1257302 diff --git a/data/meshes_obj/12138.obj b/data/meshes_obj/12138.obj new file mode 100644 index 0000000000000000000000000000000000000000..52e9d9c77a71186656736d3391c0d7d9a910203d --- /dev/null +++ b/data/meshes_obj/12138.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c44970e7dc96edfc0e98f462d12f6e085cdbe8211f9b4c3294bba9ec77768f02 +size 261407 diff --git a/data/meshes_obj/12139.obj b/data/meshes_obj/12139.obj new file mode 100644 index 0000000000000000000000000000000000000000..e3fa1e2727b92805d5c3b468fe9d11d0e0a5c468 --- /dev/null +++ b/data/meshes_obj/12139.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:587a481076c6c2c2325835784b63903612ec1c883b81c7b67c81f6641f2996d0 +size 4869934 diff --git a/data/meshes_obj/12140.obj b/data/meshes_obj/12140.obj new file mode 100644 index 0000000000000000000000000000000000000000..d4fc518d328963fe4d90b30f8286cacf193437c1 --- /dev/null +++ b/data/meshes_obj/12140.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:937350cd319d2de508b46bd62101a7f33af4f0ed1ab7cd3ade124bba7c9cb451 +size 1121638 diff --git a/data/meshes_obj/12141.obj b/data/meshes_obj/12141.obj new file mode 100644 index 0000000000000000000000000000000000000000..0da70b84c3c437ba1ca219ec841b229888e287db --- /dev/null +++ b/data/meshes_obj/12141.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f5662ba68f1b464d0ced0427db5204c83ca3cd2a4d2727795cd5a9260f66291c +size 1856777 diff --git a/data/meshes_obj/12142.obj b/data/meshes_obj/12142.obj new file mode 100644 index 0000000000000000000000000000000000000000..f6cb1328b2c020e3377e09896aa18eae98631115 --- /dev/null +++ b/data/meshes_obj/12142.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:30cef05768b32bc147d518726a185a0109de2ee918079d8f79051635c5e16fcc +size 1322430 diff --git a/data/meshes_obj/12143.obj b/data/meshes_obj/12143.obj new file mode 100644 index 0000000000000000000000000000000000000000..d252fd99bd2d1982a4b18c9ae66281fd22a7ad7c --- /dev/null +++ b/data/meshes_obj/12143.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fe22d0190841509ea1f0740b6207fbfe836642c850d4fd6437dd4dd59a0a0539 +size 725404 diff --git a/data/meshes_obj/12144.obj b/data/meshes_obj/12144.obj new file mode 100644 index 0000000000000000000000000000000000000000..df6a1e95f8cdcd63a8717bf946cd60aaf875f919 --- /dev/null +++ b/data/meshes_obj/12144.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:87c7db1602e4fb98b61f1bab332d94e8b2453d3d615332f461dd6004bfc0bd36 +size 302543 diff --git a/data/meshes_obj/12145.obj b/data/meshes_obj/12145.obj new file mode 100644 index 0000000000000000000000000000000000000000..4b41f3c2cbdaf93ee65995298134ee02c6ec8da6 --- /dev/null +++ b/data/meshes_obj/12145.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:36029ca3944191328b3d316cfb6dc8b03aa1a646c9b27d0d7471433f797bc0c7 +size 293779 diff --git a/data/meshes_obj/12146.obj b/data/meshes_obj/12146.obj new file mode 100644 index 0000000000000000000000000000000000000000..1dca3a3da676c0b0f762e778119167c4e35d3cc0 --- /dev/null +++ b/data/meshes_obj/12146.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:14a013c83da52d6cbdf27d6aa32719ff3c944c73a0235b06fc498d554b9a151f +size 374239 diff --git a/data/meshes_obj/12147.obj b/data/meshes_obj/12147.obj new file mode 100644 index 0000000000000000000000000000000000000000..929c3eb9355da607a52f8c6aa2036a7a215ac9f0 --- /dev/null +++ b/data/meshes_obj/12147.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0c0f1e1e93d3753c88d1c2b42ee96295c73adc2fe55e0b41c107f2562f549146 +size 425893 diff --git a/data/meshes_obj/12148.obj b/data/meshes_obj/12148.obj new file mode 100644 index 0000000000000000000000000000000000000000..730cc9afcf32fef0d3fa851ac4a8ad0e0a32bef8 --- /dev/null +++ b/data/meshes_obj/12148.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1bf7b8d298b2ae8071c176a79cb08e5309160843ce558d227c0103e6be9a2b1c +size 3692837 diff --git a/data/meshes_obj/12149.obj b/data/meshes_obj/12149.obj new file mode 100644 index 0000000000000000000000000000000000000000..b1a99ede208dc06d69e0068a6bfd37d5a7503913 --- /dev/null +++ b/data/meshes_obj/12149.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:29898d6958985c06c331b65a08d77610f3c30cce4bff49f5310a020f645fe119 +size 282160 diff --git a/data/meshes_obj/12150.obj b/data/meshes_obj/12150.obj new file mode 100644 index 0000000000000000000000000000000000000000..8e02414d91468089fe85700ea9d4a3879ee54342 --- /dev/null +++ b/data/meshes_obj/12150.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:abb044bc40081b10119fda6b4b47970781a4e0800b7a3da0249eea66b7016fed +size 760265 diff --git a/data/meshes_obj/12151.obj b/data/meshes_obj/12151.obj new file mode 100644 index 0000000000000000000000000000000000000000..f00acc279682105a5bdc38685820a8019a41d546 --- /dev/null +++ b/data/meshes_obj/12151.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:01fda3a48c97c7e483738bf89f8ef25a6d985441402e283dec770e9665e5dc54 +size 1167556 diff --git a/data/meshes_obj/12152.obj b/data/meshes_obj/12152.obj new file mode 100644 index 0000000000000000000000000000000000000000..3e27089f8be616066860c58529e3a82e0bf3b2cf --- /dev/null +++ b/data/meshes_obj/12152.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:459b95c9928e5d8696e1952c3e7f7399a8064cb2e5956362a14602e6ac34baeb +size 279313 diff --git a/data/meshes_obj/12153.obj b/data/meshes_obj/12153.obj new file mode 100644 index 0000000000000000000000000000000000000000..7a01a27fb05852f6b1c7472140fbbfde3c109f19 --- /dev/null +++ b/data/meshes_obj/12153.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d0bec42f5f15d6f133e6cecce78d4a8506c6cb0c4276eba9d57271e91505329e +size 1121718 diff --git a/data/meshes_obj/12154.obj b/data/meshes_obj/12154.obj new file mode 100644 index 0000000000000000000000000000000000000000..cd342b1e990b2c5b60b2975efe2befba369b66b9 --- /dev/null +++ b/data/meshes_obj/12154.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:af877beadae12a37d518b185fd8ef2e67623ff2a4877606b5eb92f1e2b3316d4 +size 1099560 diff --git a/data/meshes_obj/12155.obj b/data/meshes_obj/12155.obj new file mode 100644 index 0000000000000000000000000000000000000000..8d8a9d082f54e3d5bc0b6bd42ea7de5de5cd5f2d --- /dev/null +++ b/data/meshes_obj/12155.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:65d3119a5ba7373668b33edb03d4eeb8232db04463a71aef2434d20d977f78e4 +size 2452029 diff --git a/data/meshes_obj/12156.obj b/data/meshes_obj/12156.obj new file mode 100644 index 0000000000000000000000000000000000000000..ad35f29491c655af5a317b0d58e236a1a3d74b81 --- /dev/null +++ b/data/meshes_obj/12156.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7bb34676082c11f5b20f19d3bd9e2646b6a8b99304ff8bd53ba0b554ad0f1956 +size 1379161 diff --git a/data/meshes_obj/12157.obj b/data/meshes_obj/12157.obj new file mode 100644 index 0000000000000000000000000000000000000000..d60602c21b986395df54d24aafe652e5e4d6faba --- /dev/null +++ b/data/meshes_obj/12157.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:96e649438633f3a63b9ce76e76991dc399d7cc68b5cbd2b893203a9178355169 +size 590594 diff --git a/data/meshes_obj/12158.obj b/data/meshes_obj/12158.obj new file mode 100644 index 0000000000000000000000000000000000000000..9a29e01478877299e5d6a5f71625919c3facbdf1 --- /dev/null +++ b/data/meshes_obj/12158.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:735223c9243559357f694339c979aa6c6efddd19f1eacba773493812b9fb8c63 +size 740724 diff --git a/data/meshes_obj/12159.obj b/data/meshes_obj/12159.obj new file mode 100644 index 0000000000000000000000000000000000000000..40d517cfa36f0cc74adeb1495b794ef905f814c8 --- /dev/null +++ b/data/meshes_obj/12159.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1075bdc23d549acf09252207ef637138a08a58fd37094f9b0210793255aa59d4 +size 93502 diff --git a/data/meshes_obj/12160.obj b/data/meshes_obj/12160.obj new file mode 100644 index 0000000000000000000000000000000000000000..9da38b9f69d24b2772004d3a74a8230b91aa524d --- /dev/null +++ b/data/meshes_obj/12160.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:00143239053a02a434ebfa271509316105e6dfbf90d72b3031784c1ae7a09945 +size 82678 diff --git a/data/meshes_obj/12162.obj b/data/meshes_obj/12162.obj new file mode 100644 index 0000000000000000000000000000000000000000..6f93e816158a5fb969e6b539d6ab8141ec08458d --- /dev/null +++ b/data/meshes_obj/12162.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8dc105eee019b1991719bf030356d0184e29e1a5ed5b9d100457dcfe53d9948e +size 561352 diff --git a/data/meshes_obj/12163.obj b/data/meshes_obj/12163.obj new file mode 100644 index 0000000000000000000000000000000000000000..fdd35267438e81020439cc7ddac2c39cb3bc7976 --- /dev/null +++ b/data/meshes_obj/12163.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0f3b461c65d09e4448622ec5af405baa72305cd86e4da0ce32660d8be37b8fb4 +size 303814 diff --git a/data/meshes_obj/12164.obj b/data/meshes_obj/12164.obj new file mode 100644 index 0000000000000000000000000000000000000000..f8b68c0c577a66ccd5c0b77cc3ff77bcf5e3e289 --- /dev/null +++ b/data/meshes_obj/12164.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:527107cbf93ff91c9c7f389857068cf742b0297f4bdfd5a023d0dae3c6f754ca +size 235117 diff --git a/data/meshes_obj/12165.obj b/data/meshes_obj/12165.obj new file mode 100644 index 0000000000000000000000000000000000000000..81e5e779eac5d797fd59d6c8ec93d3951738fd5d --- /dev/null +++ b/data/meshes_obj/12165.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b114f9e0ab4a3399e1939d7d62b1d284c461c35ee282801c0ceb721abbce29c5 +size 48967 diff --git a/data/meshes_obj/12166.obj b/data/meshes_obj/12166.obj new file mode 100644 index 0000000000000000000000000000000000000000..efd8dc213b6d2e4c2c6bc7cecaeb6a7ce1775b36 --- /dev/null +++ b/data/meshes_obj/12166.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:83f2f1d07a0cbbe0bd0cdf191c95595d78fb72025765afa682548883610321d1 +size 48967 diff --git a/data/meshes_obj/12170.obj b/data/meshes_obj/12170.obj new file mode 100644 index 0000000000000000000000000000000000000000..879e9f4f22853a465972427cdd2d45af0c0a132b --- /dev/null +++ b/data/meshes_obj/12170.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:47412aaac5118354b3aa4a5bc444abe4d32d5131c3ab54ba2c4b57162e248089 +size 421200 diff --git a/data/meshes_obj/12171.obj b/data/meshes_obj/12171.obj new file mode 100644 index 0000000000000000000000000000000000000000..e39da89105cac0dd181bacf79aa19432f4097377 --- /dev/null +++ b/data/meshes_obj/12171.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:215566c1a2901c3b1d803963643f64b6aae1071f78e6e1fe5aec604bfc0568db +size 157743 diff --git a/data/meshes_obj/12173.obj b/data/meshes_obj/12173.obj new file mode 100644 index 0000000000000000000000000000000000000000..a5c54c71cf50d625a0742b2154f0b1e14ac01a4e --- /dev/null +++ b/data/meshes_obj/12173.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3fe912544a865c8a6732007d77baabfb53a485126409cef082b33780c446136b +size 142662 diff --git a/data/meshes_obj/12174.obj b/data/meshes_obj/12174.obj new file mode 100644 index 0000000000000000000000000000000000000000..f9cc23c6b260bb914bfe2085be6ea3fb2c4e0e2b --- /dev/null +++ b/data/meshes_obj/12174.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:28bc005381b17f1fd709711a213609f9a82d71ee6ff57ed188f23913e5702394 +size 136459 diff --git a/data/meshes_obj/12176.obj b/data/meshes_obj/12176.obj new file mode 100644 index 0000000000000000000000000000000000000000..099ec3f6a07882d0ca2c26d04563d0b5e14c9bfe --- /dev/null +++ b/data/meshes_obj/12176.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7bcddcfc397dfd7230a4caa028d679c998c6d91ae6cb40cbdb88972d740b759c +size 794613 diff --git a/data/meshes_obj/12177.obj b/data/meshes_obj/12177.obj new file mode 100644 index 0000000000000000000000000000000000000000..22db4c725d1a4a3da7c7fb68cd1032a5d9942342 --- /dev/null +++ b/data/meshes_obj/12177.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f6d02b80e9f740eb6032702f353e0c225ff88e40a45be420e79bb26acc37a5f8 +size 332253 diff --git a/data/meshes_obj/12178.obj b/data/meshes_obj/12178.obj new file mode 100644 index 0000000000000000000000000000000000000000..791052f00ec355b6ce52797e97535825adc556a9 --- /dev/null +++ b/data/meshes_obj/12178.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:213584f7507394c3def538d4aa7f0f8cb9de0ff537a9c4c9c8a7e8aafedc5a44 +size 529708 diff --git a/data/meshes_obj/12179.obj b/data/meshes_obj/12179.obj new file mode 100644 index 0000000000000000000000000000000000000000..3731014d7464621095c60f510621de16e6773216 --- /dev/null +++ b/data/meshes_obj/12179.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:22fa862fab6f50ba260b8cc52e429ed1befc0ddb6fd345c4f198c33233abcf79 +size 26774 diff --git a/data/meshes_obj/12181.obj b/data/meshes_obj/12181.obj new file mode 100644 index 0000000000000000000000000000000000000000..6cff697ec1dd3ef6b823d4c499b29c2f58bc87bf --- /dev/null +++ b/data/meshes_obj/12181.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9a7871ecf00b0e7ee2027798c3b5e12e65645515951bf77aa994532bdff89589 +size 40725 diff --git a/data/meshes_obj/12195.obj b/data/meshes_obj/12195.obj new file mode 100644 index 0000000000000000000000000000000000000000..6144ecf3c9ad7783089a0a898479cf137c5fd70b --- /dev/null +++ b/data/meshes_obj/12195.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a2d5109fac0c6d709838c7dfcf8c89e802a97277b452fc4787f0c5cd0d295f2c +size 242277 diff --git a/data/meshes_obj/12291.obj b/data/meshes_obj/12291.obj new file mode 100644 index 0000000000000000000000000000000000000000..abb94839d2155fb7460a6c920f953ec6c1c78a66 --- /dev/null +++ b/data/meshes_obj/12291.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:50f9f817896c8751de1f620750a47e93e1d39e6fa53f8b76e2a5674447b7abb6 +size 68325 diff --git a/data/meshes_obj/12292.obj b/data/meshes_obj/12292.obj new file mode 100644 index 0000000000000000000000000000000000000000..d2f3d9653b3228efa2671b8417f84bbaf35f50e0 --- /dev/null +++ b/data/meshes_obj/12292.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:838df099e4db593f2a25b4c7339a8274c6fb95689e97c8222c21777f28b94453 +size 38146 diff --git a/data/meshes_obj/12305.obj b/data/meshes_obj/12305.obj new file mode 100644 index 0000000000000000000000000000000000000000..466d2c1d89f7a83e68c04b1aced592f5a3b4c237 --- /dev/null +++ b/data/meshes_obj/12305.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:97210562142f089bb2ae983d4ec3b2859934e27eeb459b280820ac29abba565b +size 46626 diff --git a/data/meshes_obj/12330.obj b/data/meshes_obj/12330.obj new file mode 100644 index 0000000000000000000000000000000000000000..4a23fe6d45497ad4066c95f3c564be488029c833 --- /dev/null +++ b/data/meshes_obj/12330.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e5125c52ad3b77a154fb8a0518e0c26cdd30a9a4be2d220e53cf7045e382f437 +size 140523 diff --git a/data/meshes_obj/12354.obj b/data/meshes_obj/12354.obj new file mode 100644 index 0000000000000000000000000000000000000000..b4a38472f983aa0ffe56a039cd2e9e674d4b5178 --- /dev/null +++ b/data/meshes_obj/12354.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c35a6e650d51bce958cb26b68ad317c20d3555458fdc376c496cc839e23a2af8 +size 60975 diff --git a/data/meshes_obj/12369.obj b/data/meshes_obj/12369.obj new file mode 100644 index 0000000000000000000000000000000000000000..1e077b9856c3940ff9a04a70ce7d4ec1d9c36f1c --- /dev/null +++ b/data/meshes_obj/12369.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4d3470b4ed53b794b5edd8d23bad71a55edae42b06c4cb4e14da24936a377364 +size 19216 diff --git a/data/meshes_obj/12384.obj b/data/meshes_obj/12384.obj new file mode 100644 index 0000000000000000000000000000000000000000..5d0937823d255faaa045852bb7cd8f07470d8339 --- /dev/null +++ b/data/meshes_obj/12384.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3be186a5a5a178e43769234622bacb5ababf1997c91a90071adb9f44f30a9c78 +size 969896 diff --git a/data/meshes_obj/12390.obj b/data/meshes_obj/12390.obj new file mode 100644 index 0000000000000000000000000000000000000000..a2cc484a5edab6d04563e3695f12068524142074 --- /dev/null +++ b/data/meshes_obj/12390.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5fc44b9bcf7f3b04f7ef791d5b0a52e29a18a9fbdac8028be894fe476f24705c +size 2151603 diff --git a/data/meshes_obj/12405.obj b/data/meshes_obj/12405.obj new file mode 100644 index 0000000000000000000000000000000000000000..f04659e700885fef647047e72d56f4d88444ef87 --- /dev/null +++ b/data/meshes_obj/12405.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0439df898b306e13edfb8d3181bff127c48b66ab7f66d334efcf05d8f71d11e5 +size 223190 diff --git a/data/meshes_obj/12416.obj b/data/meshes_obj/12416.obj new file mode 100644 index 0000000000000000000000000000000000000000..3326d56042f02d37189bfb632cc151fa7d232f7c --- /dev/null +++ b/data/meshes_obj/12416.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f9a2df4a7fdcec17ffc724eb290ab441619b51ccb4815cd0b6b875de56269184 +size 209147 diff --git a/data/meshes_obj/12535.obj b/data/meshes_obj/12535.obj new file mode 100644 index 0000000000000000000000000000000000000000..e2e37231bb3914d954a23515e8091de00d631678 --- /dev/null +++ b/data/meshes_obj/12535.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fccc7bfa5be2555bb9959348b4c87f9c30b953484316ba6096a1fc9dc46bd1ed +size 66467 diff --git a/data/meshes_obj/12538.obj b/data/meshes_obj/12538.obj new file mode 100644 index 0000000000000000000000000000000000000000..3152610aa9e983a1a236aa35326ba2b9f12b5626 --- /dev/null +++ b/data/meshes_obj/12538.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4583e744e59bc7fbb8fd0f9403bcef7a5185c37c521a0c428bfc60fc668868da +size 154387 diff --git a/data/meshes_obj/12805.obj b/data/meshes_obj/12805.obj new file mode 100644 index 0000000000000000000000000000000000000000..4fa13217776a3842ae63b4700c44ebba39dc5600 --- /dev/null +++ b/data/meshes_obj/12805.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b18206a8da73a374e889c33fd2924311a690386a6c303667f0f5b0946cb0c455 +size 143212 diff --git a/data/meshes_obj/12826.obj b/data/meshes_obj/12826.obj new file mode 100644 index 0000000000000000000000000000000000000000..9a2afb5cba8192a4de2aea58fc51bd30620a4c89 --- /dev/null +++ b/data/meshes_obj/12826.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:45cf366930a0d5bcd011786a8792020be22a0a608d6c1cf6608b4fd6bc616ed2 +size 4412 diff --git a/data/meshes_obj/13229.obj b/data/meshes_obj/13229.obj new file mode 100644 index 0000000000000000000000000000000000000000..06c50cf81e410d0e77ea417679c5c87b542f7e82 --- /dev/null +++ b/data/meshes_obj/13229.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:49561248fc2231853a3a9f529198f10c6de9d70b2b5347b68632433ddf4addfd +size 42714 diff --git a/data/meshes_obj/13230.obj b/data/meshes_obj/13230.obj new file mode 100644 index 0000000000000000000000000000000000000000..509dc67b326701324acbf205da4023da56ed977a --- /dev/null +++ b/data/meshes_obj/13230.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1bf25eefac2d8839c33def1696db0ee7ad0565accb520a4c540b77359d801011 +size 444179 diff --git a/data/meshes_obj/146034872.obj b/data/meshes_obj/146034872.obj new file mode 100644 index 0000000000000000000000000000000000000000..177a12fa324cb8bd0015398aa9dea4b5c31dcdf4 --- /dev/null +++ b/data/meshes_obj/146034872.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d2884c4d4bd0001746593954ea6b1fdb7e6b6264ca4b8a84627d43dcdf53cb18 +size 952036 diff --git a/data/meshes_obj/146034876.obj b/data/meshes_obj/146034876.obj new file mode 100644 index 0000000000000000000000000000000000000000..c79040b24f1e2300575c6ba78110df772e028ce4 --- /dev/null +++ b/data/meshes_obj/146034876.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:53df810f146949b85903e6c41b7f2a3cca396b38dfb113aa9a567f2584b80735 +size 190086 diff --git a/data/meshes_obj/146034884.obj b/data/meshes_obj/146034884.obj new file mode 100644 index 0000000000000000000000000000000000000000..7298d475cc0fa6587591979316860ad728ebb6d1 --- /dev/null +++ b/data/meshes_obj/146034884.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:71e25e58eba03756ddbb026fbe33a2edeca2c230d3b05d1ca38c8361b0a0e7f0 +size 322688 diff --git a/data/meshes_obj/146034888.obj b/data/meshes_obj/146034888.obj new file mode 100644 index 0000000000000000000000000000000000000000..fd42d1489823d7387f6a392d4e6f63c779e56ed1 --- /dev/null +++ b/data/meshes_obj/146034888.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:36bc27c67b693d87c8d61c0df0c26e0daea0c7095f18637851f0fdacdaed9701 +size 467586 diff --git a/data/meshes_obj/266441657.obj b/data/meshes_obj/266441657.obj new file mode 100644 index 0000000000000000000000000000000000000000..6725e019f4b3b87ddf343498409024e16da64093 --- /dev/null +++ b/data/meshes_obj/266441657.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:432f068d3dbb369f0f460ed10f9bcc3b94b0a78e38e51da0cca6d4ab91acecde +size 184342 diff --git a/data/rag_knowledge/README.md b/data/rag_knowledge/README.md new file mode 100644 index 0000000000000000000000000000000000000000..9ebad4d3b0289246dee0a2558a1bbea11f4910b3 --- /dev/null +++ b/data/rag_knowledge/README.md @@ -0,0 +1,46 @@ +# RAG Knowledge Base + +Drop your own neuroscience knowledge files here to improve LLM interpretations. + +## Supported formats + +### JSON files (`*.json`) + +Each JSON file should be a flat object mapping region names to descriptions: + +```json +{ + "Amygdala (AMY)": "The amygdala processes threat detection and fear conditioning. It receives rapid subcortical visual input via the superior colliculus and pulvinar for fast threat assessment...", + "Hippocampal Formation (HPF)": "The hippocampus is essential for episodic memory encoding and spatial navigation. Place cells in CA1 fire at specific locations..." +} +``` + +Region names do not need to match the Allen atlas names exactly -- the system uses semantic similarity (embedding search) to find the most relevant knowledge for any query. + +### Text files (`*.txt`) + +Use `## Region Name` headers to separate entries: + +``` +## Amygdala (AMY) +The amygdala processes threat detection and fear conditioning... + +## Hippocampal Formation (HPF) +The hippocampus is essential for episodic memory encoding... +``` + +## Partial coverage is fine + +You do not need to cover every brain region. The system merges your files with a built-in fallback knowledge base (21 regions). For any region not covered by your files or the fallback, the LLM uses its own training knowledge. + +Priority order: +1. Your files in this directory (highest priority) +2. Built-in fallback knowledge base (`REGION_KNOWLEDGE` in `src/region_analyzer.py`) +3. LLM's own neuroscience training knowledge + +## Tips + +- More detail is better -- include connectivity, cell types, clinical relevance +- Multiple JSON/TXT files are merged automatically +- If two files define the same region, the last one loaded (alphabetical order) wins +- Changes take effect on the next app startup (no need to rebuild anything) diff --git a/data/rag_knowledge/builtin_regions.json b/data/rag_knowledge/builtin_regions.json new file mode 100644 index 0000000000000000000000000000000000000000..d517d72191375e67023ca5be9336bb6df8b9a205 --- /dev/null +++ b/data/rag_knowledge/builtin_regions.json @@ -0,0 +1,26 @@ +{ + "Retrosplenial Area (RSP)": "The retrosplenial cortex is involved in spatial memory, navigation, and contextual memory. It integrates information from the hippocampus and visual cortex to form spatial representations.", + "Anterior Cingulate Area (ACA)": "The anterior cingulate cortex plays roles in attention, error monitoring, conflict detection, emotional regulation, and decision-making. It connects limbic and prefrontal regions.", + "Prelimbic Area (PL)": "The prelimbic cortex is part of the medial prefrontal cortex, involved in working memory, goal-directed behavior, fear expression, and emotional regulation.", + "Infralimbic Area (ILA)": "The infralimbic cortex is involved in fear extinction, autonomic regulation, and emotional processing. It has reciprocal connections with the amygdala.", + "Orbital Area (ORB)": "The orbitofrontal cortex is critical for reward-based decision making, value encoding, and behavioral flexibility. It integrates sensory and emotional information.", + "Primary Somatosensory Area (SSp)": "The primary somatosensory cortex processes tactile information from the body including touch, pressure, temperature, and pain.", + "Supplemental Somatosensory Area (SSs)": "The supplemental somatosensory area processes higher-order tactile information and is involved in texture discrimination and object recognition by touch.", + "Primary Motor Area (MOp)": "The primary motor cortex directly controls voluntary movements by sending signals to muscles via the corticospinal tract.", + "Secondary Motor Area (MOs)": "The secondary motor area (premotor cortex) is involved in movement planning, preparation, and the selection of appropriate motor programs.", + "Primary Visual Area (VISp)": "The primary visual cortex (V1) processes basic visual features including orientation, spatial frequency, and color.", + "Anterolateral Visual Area (VISal)": "The anterolateral visual area processes complex visual features and is part of the ventral visual stream involved in object recognition.", + "Anteromedial Visual Area (VISam)": "The anteromedial visual area is part of the dorsal visual stream, involved in spatial processing and visuospatial attention.", + "Posterior Parietal Association Area (PTLp)": "The posterior parietal cortex integrates sensory information for spatial awareness, attention, and sensorimotor transformations for reaching and grasping.", + "Primary Auditory Area (AUDp)": "The primary auditory cortex processes basic acoustic features including frequency, intensity, and temporal patterns of sound.", + "Dorsal Auditory Area (AUDd)": "The dorsal auditory area is part of the auditory 'where' pathway, processing spatial aspects of sound localization.", + "Ventral Auditory Area (AUDv)": "The ventral auditory area is part of the auditory 'what' pathway, processing sound identity and complex spectral features.", + "Temporal Association Area (TEa)": "The temporal association area integrates auditory and visual information for multisensory processing and is involved in social cognition.", + "Perirhinal Area (PERI)": "The perirhinal cortex is critical for object recognition memory and serves as a gateway between neocortical areas and the hippocampus.", + "Ectorhinal Area (ECT)": "The ectorhinal cortex is involved in the processing of contextual information and forms part of the parahippocampal region.", + "Hippocampal Formation (HPF)": "The hippocampal formation is essential for episodic memory formation, spatial navigation, and memory consolidation. It includes the hippocampus proper, dentate gyrus, and subiculum.", + "Entorhinal Area (ENT)": "The entorhinal cortex is the main interface between the hippocampus and neocortex. It contains grid cells for spatial navigation and is critical for memory encoding.", + "Thalamus (TH)": "The thalamus is a major relay station for sensory and motor information. It routes signals between subcortical areas and the cortex and plays roles in consciousness, sleep, and alertness.", + "Hypothalamus (HY)": "The hypothalamus regulates homeostatic functions including body temperature, hunger, thirst, circadian rhythms, and the autonomic nervous system. It controls the pituitary gland.", + "Cerebellum (CB)": "The cerebellum coordinates voluntary movements, balance, and motor learning. It also contributes to cognitive functions including attention and language." +} diff --git a/data/rag_knowledge/example_regions.json b/data/rag_knowledge/example_regions.json new file mode 100644 index 0000000000000000000000000000000000000000..303dffd13d7f0c997964fcb6ae657632d3ca9984 --- /dev/null +++ b/data/rag_knowledge/example_regions.json @@ -0,0 +1,5 @@ +{ + "Amygdala (AMY)": "The amygdala is a bilateral almond-shaped structure in the medial temporal lobe critical for emotional processing, particularly fear conditioning and threat detection. It receives rapid subcortical visual input via the superior colliculus-pulvinar pathway for fast threat assessment, bypassing cortical processing. The basolateral complex (BLA) integrates sensory input with emotional valence, while the central nucleus (CeA) coordinates autonomic and behavioral fear responses. In resting state, the amygdala shows intrinsic connectivity with the prefrontal cortex (emotion regulation), insula (interoception), and hippocampus (emotional memory). Disrupted amygdala connectivity is implicated in anxiety disorders, PTSD, and depression.", + "Prefrontal Cortex (PFC)": "The prefrontal cortex encompasses the anterior portion of the frontal lobe and is the neural substrate for executive functions including working memory, planning, decision-making, cognitive flexibility, and impulse control. The dorsolateral PFC (dlPFC) supports working memory and cognitive control. The ventromedial PFC (vmPFC) integrates emotional valence with decision-making and is critical for value-based choices. The orbitofrontal cortex (OFC) encodes reward prediction and behavioral flexibility. In resting state, the medial PFC is a core hub of the default mode network (DMN), active during self-referential thought, future planning, and social cognition.", + "Insular Cortex (IC)": "The insula is a cortical region folded deep within the lateral sulcus, serving as a hub for interoceptive awareness -- the sense of the body's internal state. The posterior insula processes primary interoceptive signals (heartbeat, respiration, gut feelings), while the anterior insula integrates these with emotional and cognitive context to generate subjective feelings. The anterior insula is a key node in the salience network, detecting behaviorally relevant stimuli and switching between the default mode and central executive networks. It plays roles in empathy, risk assessment, addiction, and pain perception." +} diff --git a/data/roi_flow/mdn_training_all_points.npy b/data/roi_flow/mdn_training_all_points.npy new file mode 100644 index 0000000000000000000000000000000000000000..c87e14dab7ddfad03e7d96434a80f0eccab3b1a0 --- /dev/null +++ b/data/roi_flow/mdn_training_all_points.npy @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bf047435bda457ab7eb4d59940ae8463555bb92cffadaed9d13aad234123887f +size 223208 diff --git a/data/roi_flow/mdn_universal_all_grid64_entropy.bin b/data/roi_flow/mdn_universal_all_grid64_entropy.bin new file mode 100644 index 0000000000000000000000000000000000000000..55bb17ee16b9c292175fa4eaa3a49e857e8cebbd --- /dev/null +++ b/data/roi_flow/mdn_universal_all_grid64_entropy.bin @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5ef8fb7dace16f77b06b262e09dbbbd0070c21d5ac71e49b280a4af5e34fee2e +size 1048576 diff --git a/data/roi_flow/mdn_universal_all_grid64_mean_xyz3.bin b/data/roi_flow/mdn_universal_all_grid64_mean_xyz3.bin new file mode 100644 index 0000000000000000000000000000000000000000..4e79b4eb378191b3f6b811fabb3c517c182ee6ae --- /dev/null +++ b/data/roi_flow/mdn_universal_all_grid64_mean_xyz3.bin @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4157011b84e35e0a861730baa16de6a97b40ca42b5a0001356760d02a4cddaef +size 3145728 diff --git a/data/roi_flow/mdn_universal_all_grid64_mu1_xyz3.bin b/data/roi_flow/mdn_universal_all_grid64_mu1_xyz3.bin new file mode 100644 index 0000000000000000000000000000000000000000..5c1b259fef6a9332f5fc7cae3287bab5aaa452d4 --- /dev/null +++ b/data/roi_flow/mdn_universal_all_grid64_mu1_xyz3.bin @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0e64c8f46eaa1c0d3fa269440687f9ff24b561567feaa7fdbb7f4d1aa259976f +size 3145728 diff --git a/data/roi_flow/mdn_universal_all_grid64_mu2_xyz3.bin b/data/roi_flow/mdn_universal_all_grid64_mu2_xyz3.bin new file mode 100644 index 0000000000000000000000000000000000000000..11f2a1274f4bd8dbd8378520984dcb901cece6a5 --- /dev/null +++ b/data/roi_flow/mdn_universal_all_grid64_mu2_xyz3.bin @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d418aa24f897fdd78cee36f35e499d680db00fe987a72cd935ad4e09b2ea6b95 +size 3145728 diff --git a/data/roi_flow/mdn_universal_all_grid64_pi.bin b/data/roi_flow/mdn_universal_all_grid64_pi.bin new file mode 100644 index 0000000000000000000000000000000000000000..5168f8b50b89b1c0d4ff165b56728db3c33416eb --- /dev/null +++ b/data/roi_flow/mdn_universal_all_grid64_pi.bin @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1c8cf547e39333a47b43030ac903fd0eec449a12e3dd7be131dafb7caff9d8ea +size 1048576 diff --git a/data/roi_flow/mdn_universal_raw_grid64_meta.json b/data/roi_flow/mdn_universal_raw_grid64_meta.json new file mode 100644 index 0000000000000000000000000000000000000000..de7787a5460dfeb13a0176273722ec1362acc95e --- /dev/null +++ b/data/roi_flow/mdn_universal_raw_grid64_meta.json @@ -0,0 +1 @@ +{"grid":64,"axis_min":[-0.5743489265441895,-0.18553070724010468,-0.38263601064682007],"axis_max":[0.3224758803844452,0.597403883934021,0.37152180075645447],"files":{"mean_xyz3":"mdn_universal_all_grid64_mean_xyz3.bin","mu1_xyz3":"mdn_universal_all_grid64_mu1_xyz3.bin","mu2_xyz3":"mdn_universal_all_grid64_mu2_xyz3.bin","pi":"mdn_universal_all_grid64_pi.bin","entropy":"mdn_universal_all_grid64_entropy.bin"},"training_points_npy":"mdn_training_all_points.npy","dtype":"float32","layout":"C-order (x, y, z, 3) for *_xyz3; (x, y, z) for scalars","endianness":"little","mixture_mean_field":true,"model":{"hidden":200,"iters":35045,"keep_q":1.0,"min_frac":1.0},"mdn_source":"universal_all","bounds_mode":"full","bounds_pad":0.0} \ No newline at end of file diff --git a/data/roi_flow/probe_embed.npy b/data/roi_flow/probe_embed.npy new file mode 100644 index 0000000000000000000000000000000000000000..e98d0879b985fdd8373ac1c7b0a5cef0deba096d --- /dev/null +++ b/data/roi_flow/probe_embed.npy @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:baecae7d26b1d76bace68d96f005b0dfc0e737dd83383345e819b669259b5af1 +size 113384 diff --git a/data/roi_flow/probe_roi.npy b/data/roi_flow/probe_roi.npy new file mode 100644 index 0000000000000000000000000000000000000000..bde77ac686603c9ffe773ee12e4005958eb1a4ef --- /dev/null +++ b/data/roi_flow/probe_roi.npy @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:beef9cdff1f1453f9430fc9e2943b6a946203b57fb9a24044a2081bb76a377f1 +size 37752128 diff --git a/data/roi_flow/probe_roi_centers.npy b/data/roi_flow/probe_roi_centers.npy new file mode 100644 index 0000000000000000000000000000000000000000..7f85de2c1101f332adc491830910e61c35ffc993 --- /dev/null +++ b/data/roi_flow/probe_roi_centers.npy @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8a64ab89787f423cb3d4ac4e953a5fe056c4be9a7b3fe4738be529d14fac7b6b +size 12128 diff --git a/data/roi_flow/universal_soul_2sdm_rest_points.ply b/data/roi_flow/universal_soul_2sdm_rest_points.ply new file mode 100644 index 0000000000000000000000000000000000000000..e9ba2ba245db984a09ee55d1394c6041dca2e618 --- /dev/null +++ b/data/roi_flow/universal_soul_2sdm_rest_points.ply @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bf9353391ab8bea12c966de33ac9168af4510e0f72d093fd2ba387942bd0a199 +size 764433 diff --git a/data/sch400_rDCM_A.npy b/data/sch400_rDCM_A.npy new file mode 100644 index 0000000000000000000000000000000000000000..5673fbaec784f8cdcc8554c6499c1bda499f08c6 --- /dev/null +++ b/data/sch400_rDCM_A.npy @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:899e60603066760437d7222aef25364dcdb7a1b6bc6dbe9ad0933d4ca0f4da31 +size 640128 diff --git a/data/schaefer400_centroids_MNI.npy b/data/schaefer400_centroids_MNI.npy new file mode 100644 index 0000000000000000000000000000000000000000..29ddb5fee3e274e48d164633f63e145e66d1e9a2 --- /dev/null +++ b/data/schaefer400_centroids_MNI.npy @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5f00c8e62c5530bba3f876589be03600624a20a6b91c6e2be152141504feba7e +size 9728 diff --git a/data/structures.json b/data/structures.json new file mode 100644 index 0000000000000000000000000000000000000000..69fc5d562ff43557e701a3c1676221d76e740bb8 --- /dev/null +++ b/data/structures.json @@ -0,0 +1 @@ +[{"name": "neural plate", "acronym": "root", "id": 10153, "rgb_triplet": [215, 216, 216], "structure_id_path": [10153]}, {"name": "neural tube", "acronym": "NT", "id": 10154, "rgb_triplet": [208, 208, 209], "structure_id_path": [10153, 10154]}, {"name": "brain", "acronym": "Br", "id": 10155, "rgb_triplet": [224, 224, 224], "structure_id_path": [10153, 10154, 10155]}, {"name": "forebrain (prosencephalon)", "acronym": "F", "id": 10156, "rgb_triplet": [235, 214, 208], "structure_id_path": [10153, 10154, 10155, 10156]}, {"name": "gray matter of forebrain", "acronym": "FGM", "id": 10157, "rgb_triplet": [235, 214, 208], "structure_id_path": [10153, 10154, 10155, 10156, 10157]}, {"name": "telencephalon", "acronym": "Tel", "id": 10158, "rgb_triplet": [235, 214, 208], "structure_id_path": [10153, 10154, 10155, 10156, 10157, 10158]}, {"name": "cerebral cortex", "acronym": "Cx", "id": 10159, "rgb_triplet": [233, 204, 194], "structure_id_path": [10153, 10154, 10155, 10156, 10157, 10158, 10159]}, {"name": "periallocortex", "acronym": "PACx", "id": 10313, "rgb_triplet": [220, 208, 198], "structure_id_path": [10153, 10154, 10155, 10156, 10157, 10158, 10159, 10313]}, {"name": "peripaleocortex", "acronym": "PPCx", "id": 10327, "rgb_triplet": [203, 183, 168], "structure_id_path": [10153, 10154, 10155, 10156, 10157, 10158, 10159, 10313, 10327]}, {"name": "frontal agranular insular cortex (area FI)", "acronym": "FI", "id": 10329, "rgb_triplet": [193, 166, 143], "structure_id_path": [10153, 10154, 10155, 10156, 10157, 10158, 10159, 10313, 10327, 10329]}, {"name": "temporal agranular insular cortex (area TI)", "acronym": "TI", "id": 10330, "rgb_triplet": [181, 150, 127], "structure_id_path": [10153, 10154, 10155, 10156, 10157, 10158, 10159, 10313, 10327, 10330]}, {"name": "allocortex", "acronym": "ACx", "id": 10292, "rgb_triplet": [209, 206, 226], "structure_id_path": [10153, 10154, 10155, 10156, 10157, 10158, 10159, 10292]}, {"name": "paleocortex (semicortex)", "acronym": "PalCx", "id": 10306, "rgb_triplet": [191, 181, 213], "structure_id_path": [10153, 10154, 10155, 10156, 10157, 10158, 10159, 10292, 10306]}, {"name": "olfactory bulb", "acronym": "OB", "id": 10307, "rgb_triplet": [209, 206, 226], "structure_id_path": [10153, 10154, 10155, 10156, 10157, 10158, 10159, 10292, 10306, 10307]}, {"name": "anterior olfactory nucleus", "acronym": "AON", "id": 10308, "rgb_triplet": [170, 157, 198], "structure_id_path": [10153, 10154, 10155, 10156, 10157, 10158, 10159, 10292, 10306, 10308]}, {"name": "piriform cortex", "acronym": "Pir", "id": 10311, "rgb_triplet": [170, 157, 198], "structure_id_path": [10153, 10154, 10155, 10156, 10157, 10158, 10159, 10292, 10306, 10311]}, {"name": "cerebral nuclei", "acronym": "CN", "id": 10331, "rgb_triplet": [215, 233, 195], "structure_id_path": [10153, 10154, 10155, 10156, 10157, 10158, 10331]}, {"name": "amygdaloid complex", "acronym": "AMY", "id": 10361, "rgb_triplet": [201, 226, 177], "structure_id_path": [10153, 10154, 10155, 10156, 10157, 10158, 10331, 10361]}, {"name": "extended amygdala", "acronym": "EXA", "id": 10383, "rgb_triplet": [199, 228, 202], "structure_id_path": [10153, 10154, 10155, 10156, 10157, 10158, 10331, 10383]}, {"name": "bed nucleus of stria terminalis", "acronym": "BNST", "id": 10384, "rgb_triplet": [169, 215, 174], "structure_id_path": [10153, 10154, 10155, 10156, 10157, 10158, 10331, 10383, 10384]}, {"name": "basal nuclei (basal ganglia)", "acronym": "BN", "id": 10332, "rgb_triplet": [226, 234, 202], "structure_id_path": [10153, 10154, 10155, 10156, 10157, 10158, 10331, 10332]}, {"name": "striatum", "acronym": "STR", "id": 10333, "rgb_triplet": [193, 206, 167], "structure_id_path": [10153, 10154, 10155, 10156, 10157, 10158, 10331, 10332, 10333]}, {"name": "caudate nucleus", "acronym": "Ca", "id": 10334, "rgb_triplet": [153, 166, 119], "structure_id_path": [10153, 10154, 10155, 10156, 10157, 10158, 10331, 10332, 10333, 10334]}, {"name": "head of caudate", "acronym": "CaH", "id": 10335, "rgb_triplet": [145, 160, 110], "structure_id_path": [10153, 10154, 10155, 10156, 10157, 10158, 10331, 10332, 10333, 10334, 10335]}, {"name": "body of caudate", "acronym": "CaB", "id": 10336, "rgb_triplet": [153, 166, 119], "structure_id_path": [10153, 10154, 10155, 10156, 10157, 10158, 10331, 10332, 10333, 10334, 10336]}, {"name": "tail of caudate", "acronym": "CaT", "id": 10337, "rgb_triplet": [145, 160, 110], "structure_id_path": [10153, 10154, 10155, 10156, 10157, 10158, 10331, 10332, 10333, 10334, 10337]}, {"name": "putamen", "acronym": "Pu", "id": 10338, "rgb_triplet": [168, 180, 133], "structure_id_path": [10153, 10154, 10155, 10156, 10157, 10158, 10331, 10332, 10333, 10338]}, {"name": "nucleus accumbens", "acronym": "NAC", "id": 10339, "rgb_triplet": [145, 160, 110], "structure_id_path": [10153, 10154, 10155, 10156, 10157, 10158, 10331, 10332, 10333, 10339]}, {"name": "globus pallidus", "acronym": "GP", "id": 10342, "rgb_triplet": [215, 224, 191], "structure_id_path": [10153, 10154, 10155, 10156, 10157, 10158, 10331, 10332, 10342]}, {"name": "external segment of globus pallidus", "acronym": "GPe", "id": 10343, "rgb_triplet": [215, 224, 191], "structure_id_path": [10153, 10154, 10155, 10156, 10157, 10158, 10331, 10332, 10342, 10343]}, {"name": "internal segment of globus pallidus", "acronym": "GPi", "id": 10344, "rgb_triplet": [208, 217, 178], "structure_id_path": [10153, 10154, 10155, 10156, 10157, 10158, 10331, 10332, 10342, 10344]}, {"name": "claustrum", "acronym": "Cla", "id": 10346, "rgb_triplet": [170, 219, 206], "structure_id_path": [10153, 10154, 10155, 10156, 10157, 10158, 10331, 10346]}, {"name": "basal forebrain", "acronym": "BF", "id": 10349, "rgb_triplet": [221, 221, 175], "structure_id_path": [10153, 10154, 10155, 10156, 10157, 10158, 10331, 10349]}, {"name": "diencephalon", "acronym": "Die", "id": 10389, "rgb_triplet": [228, 204, 227], "structure_id_path": [10153, 10154, 10155, 10156, 10157, 10389]}, {"name": "thalamus", "acronym": "THM", "id": 10390, "rgb_triplet": [228, 204, 227], "structure_id_path": [10153, 10154, 10155, 10156, 10157, 10389, 10390]}, {"name": "subthalamus", "acronym": "SubTH", "id": 10465, "rgb_triplet": [221, 210, 230], "structure_id_path": [10153, 10154, 10155, 10156, 10157, 10389, 10465]}, {"name": "subthalamic nucleus", "acronym": "STH", "id": 10466, "rgb_triplet": [183, 153, 196], "structure_id_path": [10153, 10154, 10155, 10156, 10157, 10389, 10465, 10466]}, {"name": "hypothalamus", "acronym": "HTH", "id": 10467, "rgb_triplet": [228, 201, 217], "structure_id_path": [10153, 10154, 10155, 10156, 10157, 10389, 10467]}, {"name": "white matter of forebrain", "acronym": "FWM", "id": 10557, "rgb_triplet": [242, 241, 240], "structure_id_path": [10153, 10154, 10155, 10156, 10557]}, {"name": "ventricles of forebrain", "acronym": "FV", "id": 10595, "rgb_triplet": [35, 31, 32], "structure_id_path": [10153, 10154, 10155, 10156, 10595]}, {"name": "lateral ventricles", "acronym": "LV", "id": 10596, "rgb_triplet": [35, 31, 32], "structure_id_path": [10153, 10154, 10155, 10156, 10595, 10596]}, {"name": "anterior horn of lateral ventricle", "acronym": "aLV", "id": 10597, "rgb_triplet": [35, 31, 32], "structure_id_path": [10153, 10154, 10155, 10156, 10595, 10596, 10597]}, {"name": "body of lateral ventricle", "acronym": "bLV", "id": 10598, "rgb_triplet": [35, 31, 32], "structure_id_path": [10153, 10154, 10155, 10156, 10595, 10596, 10598]}, {"name": "atrium of lateral ventricle", "acronym": "xLV", "id": 266441657, "rgb_triplet": [35, 31, 32], "structure_id_path": [10153, 10154, 10155, 10156, 10595, 10596, 266441657]}, {"name": "posterior horn of lateral ventricle", "acronym": "pLV", "id": 10599, "rgb_triplet": [35, 31, 32], "structure_id_path": [10153, 10154, 10155, 10156, 10595, 10596, 10599]}, {"name": "inferior horn of lateral ventricle", "acronym": "iLV", "id": 10600, "rgb_triplet": [35, 31, 32], "structure_id_path": [10153, 10154, 10155, 10156, 10595, 10596, 10600]}, {"name": "third ventricle", "acronym": "3V", "id": 10602, "rgb_triplet": [35, 31, 32], "structure_id_path": [10153, 10154, 10155, 10156, 10595, 10602]}, {"name": "surface structures of forebrain", "acronym": "FSS", "id": 10609, "rgb_triplet": [200, 201, 202], "structure_id_path": [10153, 10154, 10155, 10156, 10609]}, {"name": "cerebral gyri and lobules", "acronym": "CeG", "id": 12112, "rgb_triplet": [223, 206, 206], "structure_id_path": [10153, 10154, 10155, 10156, 10609, 12112]}, {"name": "frontal lobe", "acronym": "FroL", "id": 12113, "rgb_triplet": [242, 229, 187], "structure_id_path": [10153, 10154, 10155, 10156, 10609, 12112, 12113]}, {"name": "precentral gyrus", "acronym": "PrCG", "id": 12114, "rgb_triplet": [237, 207, 55], "structure_id_path": [10153, 10154, 10155, 10156, 10609, 12112, 12113, 12114]}, {"name": "superior frontal gyrus", "acronym": "SFG", "id": 12115, "rgb_triplet": [212, 178, 53], "structure_id_path": [10153, 10154, 10155, 10156, 10609, 12112, 12113, 12115]}, {"name": "middle frontal gyrus", "acronym": "MFG", "id": 12116, "rgb_triplet": [212, 178, 53], "structure_id_path": [10153, 10154, 10155, 10156, 10609, 12112, 12113, 12116]}, {"name": "inferior frontal gyrus", "acronym": "IFG", "id": 12117, "rgb_triplet": [194, 163, 53], "structure_id_path": [10153, 10154, 10155, 10156, 10609, 12112, 12113, 12117]}, {"name": "inferior frontal gyrus, triangular part", "acronym": "IFGtr", "id": 12118, "rgb_triplet": [194, 163, 53], "structure_id_path": [10153, 10154, 10155, 10156, 10609, 12112, 12113, 12117, 12118]}, {"name": "inferior frontal gyrus, opercular part", "acronym": "IFGop", "id": 12119, "rgb_triplet": [194, 163, 53], "structure_id_path": [10153, 10154, 10155, 10156, 10609, 12112, 12113, 12117, 12119]}, {"name": "gyrus rectus (straight gyrus)", "acronym": "ReG", "id": 12121, "rgb_triplet": [184, 162, 109], "structure_id_path": [10153, 10154, 10155, 10156, 10609, 12112, 12113, 12121]}, {"name": "medial orbital gyrus", "acronym": "MOrG", "id": 12122, "rgb_triplet": [202, 183, 129], "structure_id_path": [10153, 10154, 10155, 10156, 10609, 12112, 12113, 12122]}, {"name": "anterior intermediate orbital gyrus", "acronym": "AOrG", "id": 12123, "rgb_triplet": [202, 183, 129], "structure_id_path": [10153, 10154, 10155, 10156, 10609, 12112, 12113, 12123]}, {"name": "posterior intermediate orbital gyrus", "acronym": "POrG", "id": 12124, "rgb_triplet": [202, 183, 129], "structure_id_path": [10153, 10154, 10155, 10156, 10609, 12112, 12113, 12124]}, {"name": "lateral orbital gyrus", "acronym": "LOrG", "id": 12125, "rgb_triplet": [171, 145, 88], "structure_id_path": [10153, 10154, 10155, 10156, 10609, 12112, 12113, 12125]}, {"name": "paracentral lobule, rostral part", "acronym": "PCLr", "id": 12126, "rgb_triplet": [237, 207, 55], "structure_id_path": [10153, 10154, 10155, 10156, 10609, 12112, 12113, 12126]}, {"name": "paracingulate gyrus", "acronym": "PaCG", "id": 146034872, "rgb_triplet": [234, 166, 175], "structure_id_path": [10153, 10154, 10155, 10156, 10609, 12112, 12113, 146034872]}, {"name": "frontal operculum", "acronym": "FrO", "id": 12127, "rgb_triplet": [194, 163, 53], "structure_id_path": [10153, 10154, 10155, 10156, 10609, 12112, 12113, 12127]}, {"name": "rostral gyrus", "acronym": "RoG", "id": 146034876, "rgb_triplet": [184, 162, 109], "structure_id_path": [10153, 10154, 10155, 10156, 10609, 12112, 12113, 146034876]}, {"name": "lateral olfactory gyrus", "acronym": "LOG", "id": 13229, "rgb_triplet": [170, 157, 198], "structure_id_path": [10153, 10154, 10155, 10156, 10609, 12112, 12113, 13229]}, {"name": "frontomarginal gyrus", "acronym": "FMG", "id": 146034884, "rgb_triplet": [238, 207, 56], "structure_id_path": [10153, 10154, 10155, 10156, 10609, 12112, 12113, 146034884]}, {"name": "frontal pole", "acronym": "FP", "id": 146034888, "rgb_triplet": [234, 209, 128], "structure_id_path": [10153, 10154, 10155, 10156, 10609, 12112, 12113, 146034888]}, {"name": "parietal lobe", "acronym": "ParL", "id": 12131, "rgb_triplet": [240, 218, 190], "structure_id_path": [10153, 10154, 10155, 10156, 10609, 12112, 12131]}, {"name": "postcentral gyrus", "acronym": "PoCG", "id": 12132, "rgb_triplet": [236, 129, 59], "structure_id_path": [10153, 10154, 10155, 10156, 10609, 12112, 12131, 12132]}, {"name": "supraparietal lobule", "acronym": "SPL", "id": 12133, "rgb_triplet": [228, 156, 51], "structure_id_path": [10153, 10154, 10155, 10156, 10609, 12112, 12131, 12133]}, {"name": "inferior parietal lobule", "acronym": "IPL", "id": 12134, "rgb_triplet": [211, 142, 50], "structure_id_path": [10153, 10154, 10155, 10156, 10609, 12112, 12131, 12134]}, {"name": "supramarginal gyrus", "acronym": "SMG", "id": 12135, "rgb_triplet": [211, 142, 50], "structure_id_path": [10153, 10154, 10155, 10156, 10609, 12112, 12131, 12134, 12135]}, {"name": "angular gyrus", "acronym": "AnG", "id": 12136, "rgb_triplet": [211, 142, 50], "structure_id_path": [10153, 10154, 10155, 10156, 10609, 12112, 12131, 12134, 12136]}, {"name": "precuneus", "acronym": "PrCun", "id": 12137, "rgb_triplet": [228, 156, 51], "structure_id_path": [10153, 10154, 10155, 10156, 10609, 12112, 12131, 12137]}, {"name": "paracentral lobule, caudal part", "acronym": "PCLc", "id": 12138, "rgb_triplet": [228, 156, 51], "structure_id_path": [10153, 10154, 10155, 10156, 10609, 12112, 12131, 12138]}, {"name": "parietal operculum", "acronym": "PaO", "id": 13230, "rgb_triplet": [232, 175, 102], "structure_id_path": [10153, 10154, 10155, 10156, 10609, 12112, 12131, 13230]}, {"name": "temporal lobe", "acronym": "TemL", "id": 12139, "rgb_triplet": [235, 191, 208], "structure_id_path": [10153, 10154, 10155, 10156, 10609, 12112, 12139]}, {"name": "superior temporal gyrus", "acronym": "STG", "id": 12140, "rgb_triplet": [214, 112, 160], "structure_id_path": [10153, 10154, 10155, 10156, 10609, 12112, 12139, 12140]}, {"name": "middle temporal gyrus", "acronym": "MTG", "id": 12141, "rgb_triplet": [213, 92, 146], "structure_id_path": [10153, 10154, 10155, 10156, 10609, 12112, 12139, 12141]}, {"name": "inferior temporal gyrus", "acronym": "ITG", "id": 12142, "rgb_triplet": [213, 92, 146], "structure_id_path": [10153, 10154, 10155, 10156, 10609, 12112, 12139, 12142]}, {"name": "occipitotemporal (fusiform) gyrus, temporal part", "acronym": "FuGt", "id": 12143, "rgb_triplet": [213, 92, 146], "structure_id_path": [10153, 10154, 10155, 10156, 10609, 12112, 12139, 12143]}, {"name": "transverse temporal gyrus (Heschl's gyrus)", "acronym": "TTG", "id": 12144, "rgb_triplet": [214, 112, 160], "structure_id_path": [10153, 10154, 10155, 10156, 10609, 12112, 12139, 12144]}, {"name": "planum temporale", "acronym": "PLT", "id": 12145, "rgb_triplet": [214, 112, 160], "structure_id_path": [10153, 10154, 10155, 10156, 10609, 12112, 12139, 12145]}, {"name": "temporal pole", "acronym": "TP", "id": 12146, "rgb_triplet": [200, 83, 139], "structure_id_path": [10153, 10154, 10155, 10156, 10609, 12112, 12139, 12146]}, {"name": "planum polare", "acronym": "PLP", "id": 12147, "rgb_triplet": [214, 112, 160], "structure_id_path": [10153, 10154, 10155, 10156, 10609, 12112, 12139, 12147]}, {"name": "occipital lobe", "acronym": "OccL", "id": 12148, "rgb_triplet": [231, 172, 161], "structure_id_path": [10153, 10154, 10155, 10156, 10609, 12112, 12148]}, {"name": "occipital pole", "acronym": "OP", "id": 12149, "rgb_triplet": [193, 51, 49], "structure_id_path": [10153, 10154, 10155, 10156, 10609, 12112, 12148, 12149]}, {"name": "cuneus", "acronym": "Cun", "id": 12150, "rgb_triplet": [193, 51, 49], "structure_id_path": [10153, 10154, 10155, 10156, 10609, 12112, 12148, 12150]}, {"name": "lingual gyrus", "acronym": "LiG", "id": 12151, "rgb_triplet": [193, 51, 49], "structure_id_path": [10153, 10154, 10155, 10156, 10609, 12112, 12148, 12151]}, {"name": "occipitotemporal (fusiform) gyrus, occipital part", "acronym": "FuGo", "id": 12152, "rgb_triplet": [209, 77, 70], "structure_id_path": [10153, 10154, 10155, 10156, 10609, 12112, 12148, 12152]}, {"name": "inferior occipital gyrus", "acronym": "IOG", "id": 12153, "rgb_triplet": [209, 77, 70], "structure_id_path": [10153, 10154, 10155, 10156, 10609, 12112, 12148, 12153]}, {"name": "superior occipital gyrus", "acronym": "SOG", "id": 12154, "rgb_triplet": [209, 77, 70], "structure_id_path": [10153, 10154, 10155, 10156, 10609, 12112, 12148, 12154]}, {"name": "insular lobe", "acronym": "InL", "id": 12176, "rgb_triplet": [229, 171, 154], "structure_id_path": [10153, 10154, 10155, 10156, 10609, 12112, 12176]}, {"name": "long insular gyri", "acronym": "LIG", "id": 12177, "rgb_triplet": [196, 85, 57], "structure_id_path": [10153, 10154, 10155, 10156, 10609, 12112, 12176, 12177]}, {"name": "short insular gyri", "acronym": "SIG", "id": 12178, "rgb_triplet": [209, 97, 70], "structure_id_path": [10153, 10154, 10155, 10156, 10609, 12112, 12176, 12178]}, {"name": "limbic lobe", "acronym": "LimL", "id": 12155, "rgb_triplet": [209, 206, 226], "structure_id_path": [10153, 10154, 10155, 10156, 10609, 12112, 12155]}, {"name": "cingulate gyrus", "acronym": "CgG", "id": 12156, "rgb_triplet": [239, 185, 190], "structure_id_path": [10153, 10154, 10155, 10156, 10609, 12112, 12155, 12156]}, {"name": "cingulate gyrus, rostral (anterior) part", "acronym": "CgGr", "id": 12157, "rgb_triplet": [226, 104, 128], "structure_id_path": [10153, 10154, 10155, 10156, 10609, 12112, 12155, 12156, 12157]}, {"name": "cingulate gyrus, caudal (posterior) part", "acronym": "CgGc", "id": 12158, "rgb_triplet": [223, 79, 114], "structure_id_path": [10153, 10154, 10155, 10156, 10609, 12112, 12155, 12156, 12158]}, {"name": "cingulo-parahippocampal isthmus", "acronym": "IsCPH", "id": 12159, "rgb_triplet": [196, 173, 154], "structure_id_path": [10153, 10154, 10155, 10156, 10609, 12112, 12155, 12159]}, {"name": "subcallosal gyrus (parolfactory gyrus)", "acronym": "SCG", "id": 12160, "rgb_triplet": [226, 104, 128], "structure_id_path": [10153, 10154, 10155, 10156, 10609, 12112, 12155, 12160]}, {"name": "parahippocampal gyrus", "acronym": "PHG", "id": 12162, "rgb_triplet": [210, 191, 178], "structure_id_path": [10153, 10154, 10155, 10156, 10609, 12112, 12155, 12162]}, {"name": "anterior parahippocampal gyrus", "acronym": "APH", "id": 12163, "rgb_triplet": [210, 191, 178], "structure_id_path": [10153, 10154, 10155, 10156, 10609, 12112, 12155, 12162, 12163]}, {"name": "posterior parahippocampal gyrus", "acronym": "PPH", "id": 12164, "rgb_triplet": [193, 81, 132], "structure_id_path": [10153, 10154, 10155, 10156, 10609, 12112, 12155, 12162, 12164]}, {"name": "uncus of parahippocampal gyrus", "acronym": "UN", "id": 12165, "rgb_triplet": [210, 191, 178], "structure_id_path": [10153, 10154, 10155, 10156, 10609, 12112, 12155, 12162, 12165]}, {"name": "ambiens gyrus", "acronym": "AG", "id": 12166, "rgb_triplet": [210, 191, 178], "structure_id_path": [10153, 10154, 10155, 10156, 10609, 12112, 12155, 12162, 12165, 12166]}, {"name": "hippocampal gyrus (formation)", "acronym": "HiF", "id": 12170, "rgb_triplet": [170, 157, 198], "structure_id_path": [10153, 10154, 10155, 10156, 10609, 12112, 12155, 12170]}, {"name": "head of hippocampus", "acronym": "HiH", "id": 12171, "rgb_triplet": [170, 157, 198], "structure_id_path": [10153, 10154, 10155, 10156, 10609, 12112, 12155, 12170, 12171]}, {"name": "body of hippocampus", "acronym": "HiB", "id": 12173, "rgb_triplet": [170, 157, 198], "structure_id_path": [10153, 10154, 10155, 10156, 10609, 12112, 12155, 12170, 12173]}, {"name": "tail of hippocampus", "acronym": "HiT", "id": 12174, "rgb_triplet": [170, 157, 198], "structure_id_path": [10153, 10154, 10155, 10156, 10609, 12112, 12155, 12170, 12174]}, {"name": "limen insula", "acronym": "LI", "id": 12179, "rgb_triplet": [208, 208, 209], "structure_id_path": [10153, 10154, 10155, 10156, 10609, 12179]}, {"name": "midbrain (mesencephalon)", "acronym": "M", "id": 10648, "rgb_triplet": [175, 208, 221], "structure_id_path": [10153, 10154, 10155, 10648]}, {"name": "gray matter of midbrain", "acronym": "MGM", "id": 10649, "rgb_triplet": [160, 199, 213], "structure_id_path": [10153, 10154, 10155, 10648, 10649]}, {"name": "pretectal region", "acronym": "PTR", "id": 12181, "rgb_triplet": [160, 199, 213], "structure_id_path": [10153, 10154, 10155, 10648, 10649, 12181]}, {"name": "midbrain tegmentum", "acronym": "MTg", "id": 12195, "rgb_triplet": [82, 152, 174], "structure_id_path": [10153, 10154, 10155, 10648, 10649, 12195]}, {"name": "midbrain tectum", "acronym": "MTc", "id": 12291, "rgb_triplet": [142, 189, 205], "structure_id_path": [10153, 10154, 10155, 10648, 10649, 12291]}, {"name": "superior colliculus", "acronym": "SC", "id": 12292, "rgb_triplet": [126, 179, 201], "structure_id_path": [10153, 10154, 10155, 10648, 10649, 12291, 12292]}, {"name": "inferior colliculus", "acronym": "IC", "id": 12305, "rgb_triplet": [126, 179, 201], "structure_id_path": [10153, 10154, 10155, 10648, 10649, 12291, 12305]}, {"name": "white matter of midbrain", "acronym": "MWM", "id": 10650, "rgb_triplet": [242, 241, 240], "structure_id_path": [10153, 10154, 10155, 10648, 10650]}, {"name": "cerebral peduncle (crus cerebri)", "acronym": "cpd", "id": 12330, "rgb_triplet": [242, 241, 240], "structure_id_path": [10153, 10154, 10155, 10648, 10650, 12330]}, {"name": "superior cerebellar peduncle (brachium conjunctivum)", "acronym": "scp", "id": 12354, "rgb_triplet": [242, 241, 240], "structure_id_path": [10153, 10154, 10155, 10648, 10650, 12354]}, {"name": "ventricle of midbrain", "acronym": "MV", "id": 10651, "rgb_triplet": [35, 31, 32], "structure_id_path": [10153, 10154, 10155, 10648, 10651]}, {"name": "cerebral aqueduct", "acronym": "Aq", "id": 12369, "rgb_triplet": [35, 31, 32], "structure_id_path": [10153, 10154, 10155, 10648, 10651, 12369]}, {"name": "hindbrain (rhombencephalon)", "acronym": "H", "id": 10653, "rgb_triplet": [202, 215, 238], "structure_id_path": [10153, 10154, 10155, 10653]}, {"name": "gray matter of the hindbrain", "acronym": "HGM", "id": 10654, "rgb_triplet": [202, 215, 238], "structure_id_path": [10153, 10154, 10155, 10653, 10654]}, {"name": "metencephalon", "acronym": "Met", "id": 10655, "rgb_triplet": [179, 200, 231], "structure_id_path": [10153, 10154, 10155, 10653, 10654, 10655]}, {"name": "cerebellum", "acronym": "CB", "id": 10656, "rgb_triplet": [158, 186, 225], "structure_id_path": [10153, 10154, 10155, 10653, 10654, 10655, 10656]}, {"name": "cerebellar cortex", "acronym": "CBC", "id": 10657, "rgb_triplet": [137, 173, 219], "structure_id_path": [10153, 10154, 10155, 10653, 10654, 10655, 10656, 10657]}, {"name": "cerebellar vermis", "acronym": "CBV", "id": 10658, "rgb_triplet": [118, 162, 213], "structure_id_path": [10153, 10154, 10155, 10653, 10654, 10655, 10656, 10657, 10658]}, {"name": "cerebellar hemisphere", "acronym": "CBH", "id": 10659, "rgb_triplet": [97, 151, 208], "structure_id_path": [10153, 10154, 10155, 10653, 10654, 10655, 10656, 10657, 10659]}, {"name": "paravermis of cerebellum", "acronym": "CBPV", "id": 12384, "rgb_triplet": [74, 142, 204], "structure_id_path": [10153, 10154, 10155, 10653, 10654, 10655, 10656, 10657, 10659, 12384]}, {"name": "lateral hemisphere of cerebellum", "acronym": "CBL", "id": 12390, "rgb_triplet": [74, 142, 204], "structure_id_path": [10153, 10154, 10155, 10653, 10654, 10655, 10656, 10657, 10659, 12390]}, {"name": "cerebellar deep nuclei", "acronym": "CbDN", "id": 10660, "rgb_triplet": [62, 128, 186], "structure_id_path": [10153, 10154, 10155, 10653, 10654, 10655, 10656, 10660]}, {"name": "pons", "acronym": "Pn", "id": 10661, "rgb_triplet": [175, 195, 219], "structure_id_path": [10153, 10154, 10155, 10653, 10654, 10655, 10661]}, {"name": "basilar part of pons", "acronym": "PnBa", "id": 12405, "rgb_triplet": [120, 147, 199], "structure_id_path": [10153, 10154, 10155, 10653, 10654, 10655, 10661, 12405]}, {"name": "pontine tegmentum", "acronym": "PnTg", "id": 12416, "rgb_triplet": [101, 134, 191], "structure_id_path": [10153, 10154, 10155, 10653, 10654, 10655, 10661, 12416]}, {"name": "myelencephalon (medulla oblongata)", "acronym": "Mo", "id": 10662, "rgb_triplet": [209, 215, 232], "structure_id_path": [10153, 10154, 10155, 10653, 10654, 10662]}, {"name": "pyramidal part of medulla oblongata", "acronym": "MoPy", "id": 12535, "rgb_triplet": [151, 158, 203], "structure_id_path": [10153, 10154, 10155, 10653, 10654, 10662, 12535]}, {"name": "tegmentum of medulla oblongata", "acronym": "MoTg", "id": 12538, "rgb_triplet": [131, 139, 193], "structure_id_path": [10153, 10154, 10155, 10653, 10654, 10662, 12538]}, {"name": "white matter of hindbrain", "acronym": "HWM", "id": 10668, "rgb_triplet": [242, 241, 240], "structure_id_path": [10153, 10154, 10155, 10653, 10668]}, {"name": "ventricles of hindbrain", "acronym": "HV", "id": 10669, "rgb_triplet": [35, 31, 32], "structure_id_path": [10153, 10154, 10155, 10653, 10669]}, {"name": "fourth ventricle", "acronym": "4V", "id": 12805, "rgb_triplet": [35, 31, 32], "structure_id_path": [10153, 10154, 10155, 10653, 10669, 12805]}, {"name": "central canal of medulla oblongata", "acronym": "cec", "id": 12826, "rgb_triplet": [35, 31, 32], "structure_id_path": [10153, 10154, 10155, 10653, 10669, 12826]}] \ No newline at end of file diff --git a/docs/llm_prompts_report.md b/docs/llm_prompts_report.md new file mode 100644 index 0000000000000000000000000000000000000000..2d39cf2f76d226b86177e0edca506f9f6d70d80d --- /dev/null +++ b/docs/llm_prompts_report.md @@ -0,0 +1,437 @@ +# LLM Prompts Report + +All LLM prompts used by mindVisualizer, with exact templates, parameters, code locations, and triggers. + +**To see prompts live at runtime**, pass `--debug` to any script: +```bash +python -m src.main --no-rag --debug +python examples/rdcim_propagation.py --debug +python examples/roi_flow_mode.py --debug +``` + +--- + +## 1. Initialize Region States from Global State + +**File:** `src/brain_state.py` — method `BrainStateDB.initialize_from_global()` +**Trigger:** Press `S` key (both flow mode and rDCIM mode) +**Model params:** temperature=0.4, max_tokens=4000 + +**Template:** +``` +You are a neuroscience expert. The brain is currently in this overall state: +"{global_state}" + +For each brain region below, write a SHORT (1-2 sentences) specific description of +what this region is likely doing right now given the overall brain state. + +IMPORTANT RULES: +- Be SPECIFIC about the actual cognitive content, not generic function descriptions +- Don't force every region to match the global state -- some regions may be doing + their own thing (e.g., sensory processing, homeostasis) independent of the global state +- First consider what the region generally does, then derive what it's specifically + doing in this context +- Focus on CURRENT ACTIVITY, not general capabilities + +Regions: +{regions} + +Reply as JSON object mapping region name to state description. Example: +{"Amygdala (AMY)": "Low-level monitoring for threats; no active fear processing", ...} +Only output valid JSON, nothing else. +``` + +**Parameters:** +- `{global_state}` — user-provided description (e.g., "someone feeling anxious") or default "a resting state with spontaneous mind-wandering" +- `{regions}` — newline-separated list of region names (batched in groups of ~30) + +**Output:** JSON object mapping region name to state description string. + +--- + +## 2. Validate Perturbation + +**File:** `src/brain_state.py` — method `BrainStateDB.validate_perturbation()` +**Trigger:** Called internally before applying a perturbation +**Model params:** temperature=0.2, max_tokens=300 + +**Template:** +``` +You are a neuroscience expert. Evaluate whether this perturbation makes sense +for the specified brain region. + +Region: {region} +Current state: {current} +Requested perturbation: "{perturbation}" + +Consider: +1. What does this brain region actually do? (its primary functions) +2. Is the requested perturbation something this region CAN process? +3. If not, what WOULD be an appropriate perturbation for this region? + +Reply as JSON: +{ + "valid": true/false, + "region_function": "brief description of what this region does", + "warning": "warning if questionable (empty string if fine)", + "suggestion": "suggested alternative perturbation if invalid (empty string if valid)" +} +Only output valid JSON. +``` + +**Parameters:** +- `{region}` — region name +- `{current}` — current state description +- `{perturbation}` — the requested perturbation text + +**Output:** JSON object with `valid`, `region_function`, `warning`, `suggestion`. + +--- + +## 3. Propose Perturbations + +**File:** `src/brain_state.py` — method `BrainStateDB.propose_perturbations()` +**Trigger:** Press `P` key in rDCIM mode +**Model params:** temperature=0.5, max_completion_tokens=400 +**Note:** Uses OpenAI SDK directly (not LangChain) for reliability in background threads. + +**Template:** +``` +You are a neuroscience expert. A user wants to perturb a brain region +in a resting-state simulation. + +Region: {short_name} +Current state: "{current}" + +Based on the region's known functions and its current state, propose +exactly 4 different plausible ways this region's state could change. +Each should be specific to this region's actual function, a realistic +state change, described in 1 short sentence, and diverse from each other. + +Reply as a JSON array of exactly 4 strings: +["perturbation 1", "perturbation 2", "perturbation 3", "perturbation 4"] +Only output valid JSON. +``` + +**Parameters:** +- `{short_name}` — region name (abbreviated if >80 chars) +- `{current}` — current state description + +**Output:** JSON array of 4 perturbation strings. Falls back to defaults if API fails. + +--- + +## 4. Alter Region State + +**File:** `src/brain_state.py` — method `BrainStateDB.alter_region_state()` +**Trigger:** After user selects a perturbation (keys 1-5 in rDCIM mode) +**Model params:** temperature=0.3, max_tokens=200 + +**Template:** +``` +You are a neuroscience expert. A brain region's state needs to be modified. + +Region: {region} +Current state: {current} +Modification requested: {modification} + +Write a new SHORT (1-2 sentences) specific state description that incorporates +the requested modification while staying neuroscientifically plausible. +Only output the new state description, nothing else. +``` + +**Parameters:** +- `{region}` — region name +- `{current}` — current state +- `{modification}` — the selected perturbation + +**Output:** Plain text — the new state description (1-2 sentences). + +--- + +## 5. Propagate Through Graph (rDCIM Mode) + +**File:** `src/brain_state.py` — method `BrainStateDB.propagate_through_graph()` +**Trigger:** Press `Shift+P` in rDCIM mode +**Model params:** temperature=0.3, max_tokens=300 +**Called once per affected region (depth-by-depth).** + +**Template:** +``` +You are a computational neuroscientist analyzing intrinsic information flow in a resting-state brain network (rs-fMRI effective connectivity). + +TARGET REGION: {target} +TARGET PREVIOUS STATE: {target_state} + +INCOMING SIGNAL FROM: {source} +INCOMING SIGNAL STRENGTH: {strength} (connection weight: {weight:.4f}) +CONNECTION TYPE: {sign} +SOURCE REGION'S CURRENT STATE: "{source_state}" +{context} +CRITICAL INSTRUCTIONS: +1. NO SEMANTIC ECHOING: Do not simply copy the semantic concept of the source region. You must TRANSLATE the incoming signal into the strict anatomical and functional domain of the TARGET REGION. If the source is about "visual beauty", the motor cortex should NOT start "appreciating beauty" -- it should show changes in motor readiness or postural tone. +2. RESTING-STATE CONTEXT: The connectivity data reflects intrinsic resting-state dynamics. Information flow here represents spontaneous internal cognition or modulation of the target region's resting equilibrium. +3. INHIBITORY vs EXCITATORY: If the connection is INHIBITORY, the incoming signal SUPPRESSES or DAMPENS the target region's activity. If EXCITATORY, it AMPLIFIES or FACILITATES the target's function. This fundamentally changes the nature of the state change. +4. INTRINSIC DYNAMICS: Focus on how the target region's OWN function shifts, not on relaying the source's content. +5. Output ONLY the precise description of the target region's new state (1-2 sentences). No conversational filler. +``` + +**Parameters:** +- `{target}` — target region name +- `{target_state}` — target's previous state +- `{source}` — source region name +- `{source_state}` — source region's current state +- `{strength}` — "STRONG (well above average)", "moderate", or "weak (below average)" +- `{sign}` — "EXCITATORY (positive connection weight)" or "INHIBITORY (negative connection weight)" +- `{weight}` — absolute connection weight (float) +- `{context}` — additional context showing 1-3 other strong un-perturbed connections + +**Output:** Plain text — new state description (1-2 sentences). + +--- + +## 6. Propagate Through Regions (Flow Mode) + +**File:** `src/brain_state.py` — method `BrainStateDB.propagate_through_regions()` +**Trigger:** Press `Shift+S` in flow mode with active probe +**Model params:** temperature=0.3, max_tokens=300 + +**Template:** +``` +You are a computational neuroscientist analyzing intrinsic information flow in a resting-state brain network. + +TARGET REGION: {target} +TARGET PREVIOUS STATE: {target_state} + +INCOMING SIGNAL FROM: {source} +INCOMING SIGNAL STRENGTH: {strength} +SOURCE REGION'S CURRENT STATE: "{source_state}" + +CRITICAL INSTRUCTIONS: +1. NO SEMANTIC ECHOING: Do not simply copy the semantic concept of the source region. You must TRANSLATE the incoming signal into the strict anatomical and functional domain of the TARGET REGION ({target}). For example, if the source is a visual area processing "edge detection" and the target is a motor area, do NOT say the motor area is doing "edge detection" -- describe how the motor area's OWN function shifts in response. +2. RESTING-STATE CONTEXT: This is intrinsic resting-state dynamics, not task-driven activity. Describe subtle modulations, not dramatic activations. +3. INTRINSIC DYNAMICS: Focus on how {target}'s OWN function shifts given the incoming signal. The target region does what IT does, influenced by the source -- not what the source does. + +Output ONLY the precise description of {target}'s new state (1-2 sentences). No labels, no prefixes. +``` + +**Parameters:** +- `{target}` — target region name +- `{target_state}` — target's previous state +- `{source}` — source region name +- `{source_state}` — source's current state +- `{strength}` — "STRONG", "moderate", or "weak" + +**Output:** Plain text — new state description (1-2 sentences). + +--- + +## 7. Summarize Changes (Network-Level Overview) + +**File:** `src/brain_state.py` — method `BrainStateDB.summarize_changes()` +**Trigger:** Called automatically after propagation completes +**Model params:** temperature=0.3, max_tokens=800 + +**Template:** +``` +You are a network neuroscientist analyzing a macroscopic shift in resting-state brain activity based on effective connectivity changes. + +INITIAL BRAIN STATE MAP: +{comparisons_before} + +POST-PROPAGATION BRAIN STATE MAP: +{comparisons_after} + +CRITICAL INSTRUCTIONS: +1. DO NOT list the regions or compare them one by one. +2. This is a resting-state brain network -- interpret changes as shifts in intrinsic functional organization, not external stimulus-response narratives. +3. FOCUS ON INTRINSIC STATES: Synthesize this data into ONE coherent paragraph explaining the overall shift in the subject's internal cognitive, emotional, or physiological baseline. +4. NETWORK LEVEL INTEGRATION: Identify the broad functional domains driving the new equilibrium and describe the holistic network-level transition based purely on the provided state changes. + +Provide your coherent resting-state network insight below: +``` + +**Parameters:** +- `{comparisons_before}` — newline-separated before states: `- Region: "state"` +- `{comparisons_after}` — newline-separated after states: `- Region: "state"` + +**Output:** One coherent paragraph describing the network-level shift. + +--- + +## 8. Generate Information Flow Story + +**File:** `src/brain_state.py` — method `BrainStateDB.generate_flow_story()` +**Trigger:** Called automatically after propagation completes +**Model params:** temperature=0.4, max_tokens=800 + +**Template:** +``` +You are a science writer narrating how a signal traveled through a resting-state brain network. + +SIGNAL FLOW PATH (in order of propagation): +{flow_path} + +Write a SHORT narrative story (2-3 paragraphs) of how the information traveled through the brain: +- Start with where the signal originated and what it carried +- Describe how each region it reached processed and TRANSFORMED the signal according to its own function +- Highlight how the signal's meaning changed as it moved through different functional domains +- End with the overall effect on the brain's resting state + +RULES: +- Do NOT list regions mechanically -- weave them into a flowing narrative +- Use concrete, vivid language about what each region actually does +- Show how the signal was transformed at each hop, not just passed along +- Keep it grounded in neuroscience but accessible to a general audience +``` + +**Parameters:** +- `{flow_path}` — formatted flow steps, e.g.: + ``` + ORIGIN -- Region: "before" -> "after" + DEPTH 1 (excitatory) -- Region: "before" -> "after" + DEPTH 2 (inhibitory) -- Region: "before" -> "after" + ``` + +**Output:** 2-3 paragraph narrative story. + +--- + +## 9. Probe Flow Analysis (RAG Chain) + +**File:** `src/region_analyzer.py` — function `create_rag_chain()` +**Trigger:** Press `Shift+G` in flow mode (when RAG is enabled) +**Model params:** temperature=0.3, max_tokens=1500 + +**System prompt template:** +``` +You are a neuroscience expert interpreting information flow in a resting-state brain. + +Reference knowledge about relevant brain regions: +{context} + +{question} + +CRITICAL INSTRUCTIONS -- read carefully before answering: +1. Do NOT list which regions the probe passed through -- the user already knows that. +2. Do NOT give a generic answer like "this flow is associated with the default mode network" -- that is too shallow. +3. DO explain what SPECIFIC INFORMATION is likely flowing along this exact path. +4. DO describe the functional MEANING of this particular flow trajectory. +5. DO explain how each region transforms the signal before passing it on. +6. DO consider laterality. +7. This is a RESTING-STATE brain -- describe what spontaneous cognitive process would produce this exact flow. +``` + +**Parameters:** +- `{context}` — retrieved RAG documents (top-10 semantic matches from knowledge base) +- `{question}` — the full analysis question (see Prompt 11 below) + +--- + +## 10. Probe Flow Analysis (Direct / No-RAG) + +**File:** `src/region_analyzer.py` — function `_call_direct_responses_api()` +**Trigger:** Press `Shift+G` in flow mode (with `--no-rag` flag) +**API:** OpenAI Responses API (not LangChain) +**Model params:** reasoning.effort="low", max_output_tokens=2500 + +**Instructions (system-level):** +``` +You are a neuroscience expert interpreting information flow in a resting-state brain. + +Do NOT repeat the region list. +Do explain: +1. what specific information is likely flowing, +2. how the signal is transformed across regions, +3. what spontaneous resting-state process could produce this path, +4. the functional purpose of this exact trajectory. +Be concrete, not generic. +``` + +**Input:** The full question (see Prompt 11). + +--- + +## 11. Probe Analysis Question Construction + +**File:** `src/region_analyzer.py` — function `analyze_with_gpt()` +**Used by:** Both Prompt 9 (RAG) and Prompt 10 (no-RAG) + +**Question template:** +``` +A probe was placed in a resting-state brain and followed the intrinsic information +flow field through these regions: + +{formatted_transitions} + +Analyze this SPECIFIC flow pathway in depth: +1. What concrete information is likely being carried along this path? +2. How does the information CHANGE and get TRANSFORMED as it passes through each region? +3. What spontaneous resting-state cognitive process would produce this EXACT flow? +4. What is the functional PURPOSE of information flowing in this exact direction and order? +Do NOT repeat the region list or give a generic network label. Give deep, specific insight. +Keep your response SHORT and focused — 2-3 concise paragraphs maximum. +``` + +**Parameters:** +- `{formatted_transitions}` — output of `format_transitions_text()`, listing regions in order with enriched context including: + - **Interaction type**: "passed through center", "traversed mid-region", "briefly touched surface", "passed through periphery", or "nearby (did not enter, ~X.Xmm away)" + - **Penetration depth**: 0-100% normalized distance to region center + - **Traversal direction**: anatomical direction (e.g., "moving upward and anteriorly") + - **Entry/exit positions**: relative position within the region (e.g., "upper-left-anterior") + - **Hemisphere**: left/right when applicable + - **Flow strength, entropy, enclosing regions**: as before + - **Subregion**: extra parcellation subregion name when available + +--- + +## 12. ROI Flow Interpretation (ROI Flow Mode) + +**File:** `src/roi_flow.py` — class `ROIFlowLLM.interpret_roi_flow()` +**Trigger:** Press `Shift+G` in ROI flow mode after recording a path +**API:** OpenAI Responses API +**Model params:** reasoning.effort="low", max_output_tokens=2500 +**Note:** ROI flow mode uses its own manifold-space MDN flow field (downloaded from HuggingFace), not the brain-space field used by flow mode. + +**Instructions:** +``` +You are a neuroscientist interpreting brain state dynamics from a manifold flow +simulation. The user traced a path through a learned neural manifold (a +dimensionality-reduced representation of resting-state brain dynamics). You are +given how each ROI's contribution changed along this path. + +IMPORTANT: The delta values do NOT mean regions became more or less 'active' in +a simple sense. They measure how each ROI's CONTRIBUTION to the overall brain +state shifted — some regions contribute more to the new state, some less. +A positive delta means the region became a stronger contributor; negative means +it became a weaker contributor. This is a transition in the brain's dynamic state. + +Analyze what this particular state transition might mean. Do NOT list the +individual ROI changes — instead, synthesize them into ONE coherent picture of +what cognitive or neural process could underlie this specific shift in brain +dynamics. Consider the spatial pattern (which networks gained vs lost +contribution), the directionality, and what spontaneous resting-state process +would produce this exact transition. + +Keep your response SHORT — 2-3 concise paragraphs maximum. +``` + +**Input:** Structured analysis text from `ROIFlowAnalyzer.build_llm_context()` containing: +- Top changed ROIs (positive and negative delta) with brain region names, hemisphere, and MNI coordinates +- Direction of bulk information flow (anterior/posterior, left/right) +- Flow pattern type (one-to-many, many-to-one, distributed, bilateral_split) +- Directional analysis (L/R, A/P, S/I shift in mm) + +--- + +## How to Modify Prompts + +All prompts are defined as inline string templates at the code locations listed above. To change a prompt: + +1. Open the file listed above +2. Find the `ChatPromptTemplate.from_template(...)` or instructions string +3. Edit the template text directly +4. Parameters in `{curly_braces}` are filled at runtime -- keep the variable names unchanged +5. Use `--debug` to verify your changes at runtime + +The prompts are intentionally kept as inline strings (not in separate files) so they are easy to find and modify alongside the logic that uses them. diff --git a/examples/flow_probe_example.py b/examples/flow_probe_example.py new file mode 100644 index 0000000000000000000000000000000000000000..5df2adb048e169eb0bab2788bb2b5bbe499d3167 --- /dev/null +++ b/examples/flow_probe_example.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +"""Example: MDN Flow Probe with brain state propagation. + +Demonstrates the flow-based visualization mode: + 1. Launch particle flow with default settings + 2. Place probe(s) in the flow field + 3. Watch probes follow the flow and highlight brain regions + 4. Analyze transitions with GPT + 5. (Optional) Initialize brain states and propagate perturbations + +Usage: + # Basic flow visualization with single probe + python examples/flow_probe_example.py + + # Multi-probe with branching (for state propagation) + python examples/flow_probe_example.py --multi-probe 4 --branching + + # Skip RAG, use GPT's own knowledge + python examples/flow_probe_example.py --no-rag + + # High-quality model with debug (prints LLM prompts) + python examples/flow_probe_example.py --no-rag --hq --debug + + # Extra parcellation is auto-detected if you run the setup script: + # python scripts/setup_extra_parcellation.py + # Or provide a custom NIfTI atlas: + # python examples/flow_probe_example.py --extra-parcellation my_atlas.nii.gz + + # State propagation mode (4 probes + branching) + python examples/flow_probe_example.py --multi-probe 4 --branching + +Interactive keys: + g → place probe (then click) + G (shift) → analyze path with GPT (async, non-blocking) + b → toggle branching (MDN component uncertainty splitting) + n → cycle multi-probe count (1/4/8) + s → initialize brain region states + S (shift) → propagate state changes through probe path + c → clear all probes +""" + +import sys +from pathlib import Path + +# This example simply launches the main visualizer with recommended defaults. +# All the probe, branching, multi-probe, and brain state features are built +# into the main module. + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from src.main import main + +if __name__ == "__main__": + main() diff --git a/examples/rdcim_propagation.py b/examples/rdcim_propagation.py new file mode 100644 index 0000000000000000000000000000000000000000..4fa9683e6abe0a63b7cb6e2bfdadfb9c75433959 --- /dev/null +++ b/examples/rdcim_propagation.py @@ -0,0 +1,1012 @@ +#!/usr/bin/env python3 +"""rDCIM Direct Propagation Visualizer. + +Uses the rDCIM (regression Dynamic Causal Modeling) effective connectivity +matrix directly to simulate information propagation between brain regions. + +This is an alternative to the MDN flow-based visualization: instead of following +a continuous vector field, we use the discrete ROI-to-ROI connectivity matrix +to model how perturbations in one region propagate through the brain network. + +Usage: + python examples/rdcim_propagation.py + python examples/rdcim_propagation.py --global-state "someone feeling anxious" + python examples/rdcim_propagation.py --perturb 42 --perturbation "sudden fear response" + +Key bindings: + Click select ROI to perturb + p perturb selected ROI (prompts in console) + P (shift+p) propagate perturbation through network + s initialize brain states from global state + r reset all states + +/- increase/decrease connection threshold + t toggle connection labels + Escape quit +""" + +import argparse +import json +import os +import sys + +from pathlib import Path + +import numpy as np +import vtk + +# Add parent to path for imports +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from src.brain_state import BrainStateDB + + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +DATA_DIR = PROJECT_ROOT / "data" +DEFAULT_RDCIM = DATA_DIR / "sch400_rDCM_A.npy" +DEFAULT_CENTROIDS = DATA_DIR / "schaefer400_centroids_MNI.npy" +DEFAULT_STATE_FILE = DATA_DIR / "brain_states_rdcim.json" + +# Schaefer 400 network names (7-network parcellation) +NETWORK_COLORS = { + "Vis": (0.55, 0.0, 0.75), # purple + "SomMot": (0.0, 0.4, 0.8), # blue + "DorsAttn": (0.0, 0.7, 0.0), # green + "SalVentAttn": (0.8, 0.0, 0.5), # magenta + "Limbic": (0.9, 0.7, 0.0), # gold + "Cont": (0.9, 0.4, 0.0), # orange + "Default": (0.8, 0.2, 0.2), # red +} + + +def _ensure_ssl(): + try: + import certifi + cert_file = certifi.where() + cur = os.environ.get("SSL_CERT_FILE", "") + if not cur or not os.path.isfile(cur): + os.environ["SSL_CERT_FILE"] = cert_file + cur2 = os.environ.get("REQUESTS_CA_BUNDLE", "") + if not cur2 or not os.path.isfile(cur2): + os.environ["REQUESTS_CA_BUNDLE"] = cert_file + except ImportError: + pass + + +def load_rdcim(path: Path) -> np.ndarray: + """Load rDCIM effective connectivity matrix.""" + if not path.exists(): + raise FileNotFoundError(f"Missing rDCIM file: {path}") + A = np.load(path) + A = np.asarray(A, dtype=np.float32) + if A.ndim != 2 or A.shape[0] != A.shape[1]: + raise RuntimeError(f"rDCIM must be square, got {A.shape}") + print(f"[rDCIM] loaded {A.shape[0]}x{A.shape[1]} matrix") + return A + + +def load_centroids(path: Path) -> np.ndarray: + """Load ROI centroid positions in MNI space.""" + if not path.exists(): + raise FileNotFoundError(f"Missing centroids: {path}") + P = np.load(path).astype(np.float32) + if P.ndim != 2 or P.shape[1] not in (2, 3): + raise RuntimeError(f"Centroids must be Nx3, got {P.shape}") + if P.shape[1] == 2: + P = np.c_[P, np.zeros((P.shape[0], 1), np.float32)] + print(f"[centroids] loaded {P.shape[0]} ROIs") + return P + + +def get_roi_names(R: int) -> list[str]: + """Generate Schaefer-style ROI names.""" + names = [] + for i in range(R): + hemi = "LH" if i < R // 2 else "RH" + names.append(f"{hemi}_ROI_{i+1:03d}") + return names + + +def map_rois_to_regions(centroids: np.ndarray, roi_names: list[str]) -> list[str]: + """Map each ROI centroid to its nearest Allen atlas brain region. + + Uses a voxel label grid (same as mesh_overlay) for robust mapping. + Returns updated ROI names like 'LH_ROI_042 (precentral gyrus)'. + """ + from src.mesh_overlay import FlowMeshOverlay + import vtk + + alignment_file = DATA_DIR / "brain_alignment.json" + mesh_dir = DATA_DIR / "meshes" + + # Dummy renderer/window (no display needed) + ren = vtk.vtkRenderer() + win = vtk.vtkRenderWindow() + win.SetOffScreenRendering(1) + win.AddRenderer(ren) + + try: + overlay = FlowMeshOverlay(ren=ren, win=win, + mesh_dir=mesh_dir, + alignment_file=alignment_file) + grid_cache = DATA_DIR / "label_grid_cache.npz" + if not overlay.load_label_grid(grid_cache): + overlay.build_label_grid() + overlay.save_label_grid(grid_cache) + except Exception as e: + print(f"[roi-map] Could not build label grid: {e}") + return roi_names + + mapped_names = [] + matched = 0 + for i, (name, pos) in enumerate(zip(roi_names, centroids)): + key = overlay.get_region_at_point(pos) + if key is None: + key = overlay.find_nearest_region(pos, search_radius=4) + if key is not None: + region_name = overlay.get_region_name(key) + hemi = overlay.get_hemisphere_label(key, pos) + hemi_tag = f", {hemi}" if hemi else "" + + # Add relative position within the region + center = overlay.get_mesh_center(key) + bounds = overlay.get_mesh_bounds(key) + pos_tag = "" + if center is not None and bounds is not None: + diff = pos - center + extent = np.array([bounds[1]-bounds[0], bounds[3]-bounds[2], + bounds[5]-bounds[4]]) + extent = np.maximum(extent, 1e-6) + rel = diff / (extent * 0.5) + parts = [] + if abs(rel[2]) > 0.3: + parts.append("dorsal" if rel[2] > 0 else "ventral") + if abs(rel[0]) > 0.3: + parts.append("lateral" if abs(rel[0]) > 0.5 else "medial") + if abs(rel[1]) > 0.3: + parts.append("anterior" if rel[1] > 0 else "posterior") + if parts: + pos_tag = f", near {' '.join(parts)} section" + else: + pos_tag = ", near center" + + mapped_names.append(f"{name} ({region_name}{hemi_tag}{pos_tag})") + matched += 1 + else: + # Still add hemisphere based on x-coordinate for unmapped ROIs + hemi_label = "left hemisphere" if pos[0] < 0 else "right hemisphere" + mapped_names.append(f"{name} ({hemi_label})") + + print(f"[roi-map] Mapped {matched}/{len(roi_names)} ROIs to Allen regions") + return mapped_names + + +def get_strongest_connections(A: np.ndarray, source_idx: int, + top_k: int = 20, depth: int = 2) -> list[dict]: + """Get strongest outgoing connections from source, with multi-hop propagation. + + Args: + A: connectivity matrix (R x R) + source_idx: source ROI index + top_k: max connections per level + depth: propagation depth + + Returns: + List of {target_idx, weight, depth, path} + """ + R = A.shape[0] + Ac = A.copy() + np.fill_diagonal(Ac, 0.0) + + results = [] + visited = {source_idx} + current_level = [source_idx] + + for d in range(1, depth + 1): + next_level = [] + for src in current_level: + weights = Ac[src] + abs_w = np.abs(weights) + # Get top-k strongest + if len(abs_w) > top_k: + top_idx = np.argpartition(abs_w, -top_k)[-top_k:] + else: + top_idx = np.arange(len(abs_w)) + top_idx = top_idx[abs_w[top_idx] > 0] + + for ti in top_idx: + if ti in visited: + continue + results.append({ + "target_idx": int(ti), + "weight": float(weights[ti]), + "abs_weight": float(abs_w[ti]), + "depth": d, + "source_idx": int(src), + }) + visited.add(int(ti)) + next_level.append(int(ti)) + + current_level = next_level + if not current_level: + break + + # Sort by absolute weight + results.sort(key=lambda x: x["abs_weight"], reverse=True) + return results + + +class RDCIMVisualizer: + """Interactive VTK visualizer for rDCIM connectivity.""" + + def __init__(self, A: np.ndarray, centroids: np.ndarray, + roi_names: list[str], brain_state_db: BrainStateDB): + self.A = A + self.centroids = centroids + self.R = A.shape[0] + self.roi_names = roi_names + self.brain_state_db = brain_state_db + self.selected_roi = None + self.connection_threshold = 0.02 # fraction of max |A| + self.show_labels = False + self._connection_actors = [] + self._label_actors = [] + self._roi_actors = [] + self._highlight_actor = None + self._state_label_actors = [] # 3D text labels showing states next to ROIs + self._propagation_line_actors = [] # animated connection lines during propagation + self._perturb_proposals = None # list of 4 proposals when ready + self._perturb_region = None # region name being perturbed + self._perturb_waiting = False # True while waiting for user to press 1-5 + self._anim_steps = [] # propagation steps for looped animation + self._anim_source_idx = None # source ROI for animation + self._anim_frame = 0 # current animation frame + self._anim_playing = False # True while loop animation is active + self.propagation_depth = 2 # user-adjustable depth for Shift+P + self.propagation_top_k = 10 # connections per level + + # Compute stats + Ac = A.copy() + np.fill_diagonal(Ac, 0.0) + self.max_weight = float(np.abs(Ac).max()) if Ac.size else 1.0 + + # Setup VTK + self.ren = vtk.vtkRenderer() + self.ren.SetBackground(0.05, 0.05, 0.1) + + self.win = vtk.vtkRenderWindow() + self.win.AddRenderer(self.ren) + self.win.SetSize(1400, 900) + self.win.SetWindowName("rDCIM Propagation Visualizer") + + self._create_roi_spheres() + self._create_text_overlay() + + self.picker = vtk.vtkCellPicker() + self.picker.SetTolerance(0.005) + + def _create_roi_spheres(self): + """Create sphere actors for each ROI.""" + # Normalize centroids to reasonable scale + center = self.centroids.mean(axis=0) + scale = max(1.0, float(np.abs(self.centroids - center).max())) + + for i in range(self.R): + sphere = vtk.vtkSphereSource() + pos = self.centroids[i] + sphere.SetCenter(float(pos[0]), float(pos[1]), float(pos[2])) + sphere.SetRadius(1.5) + sphere.SetThetaResolution(12) + sphere.SetPhiResolution(12) + + mapper = vtk.vtkPolyDataMapper() + mapper.SetInputConnection(sphere.GetOutputPort()) + + actor = vtk.vtkActor() + actor.SetMapper(mapper) + + # Color by network (based on position heuristic) + r, g, b = 0.6, 0.6, 0.6 + actor.GetProperty().SetColor(r, g, b) + actor.GetProperty().SetOpacity(0.7) + + self.ren.AddActor(actor) + self._roi_actors.append(actor) + + self.ren.ResetCamera() + + def _create_text_overlay(self): + """Create info text actors.""" + self._info_actor = vtk.vtkTextActor() + self._info_actor.SetInput("Click ROI to select | p: perturb | " + "Shift+P: propagate | s: init states") + tp = self._info_actor.GetTextProperty() + tp.SetColor(1, 1, 1) + tp.SetFontSize(13) + tp.SetFontFamilyToCourier() + self._info_actor.SetPosition(10, 10) + self.ren.AddActor(self._info_actor) + + # Selected ROI info / perturbation options (top-center yellow) + self._sel_actor = vtk.vtkTextActor() + self._sel_actor.SetInput("") + tp2 = self._sel_actor.GetTextProperty() + tp2.SetColor(1.0, 0.9, 0.3) + tp2.SetFontSize(14) + tp2.SetFontFamilyToCourier() + tp2.SetJustificationToCentered() + tp2.SetVerticalJustificationToTop() + self._sel_actor.GetPositionCoordinate().SetCoordinateSystemToNormalizedDisplay() + self._sel_actor.GetPositionCoordinate().SetValue(0.5, 0.97) + self.ren.AddActor(self._sel_actor) + + # Propagation summary (top-left, green) + self._summary_actor = vtk.vtkTextActor() + self._summary_actor.SetInput("") + tp3 = self._summary_actor.GetTextProperty() + tp3.SetColor(0.8, 1.0, 0.8) + tp3.SetFontSize(11) + tp3.SetFontFamilyToCourier() + tp3.SetJustificationToLeft() + tp3.SetVerticalJustificationToTop() + self._summary_actor.GetPositionCoordinate().SetCoordinateSystemToNormalizedDisplay() + self._summary_actor.GetPositionCoordinate().SetValue(0.01, 0.97) + self._summary_actor.VisibilityOff() + self.ren.AddActor(self._summary_actor) + + # Information flow story (top-right, light blue) + self._story_actor = vtk.vtkTextActor() + self._story_actor.SetInput("") + tp4 = self._story_actor.GetTextProperty() + tp4.SetColor(0.7, 0.9, 1.0) + tp4.SetFontSize(11) + tp4.SetFontFamilyToCourier() + tp4.SetJustificationToRight() + tp4.SetVerticalJustificationToTop() + self._story_actor.GetPositionCoordinate().SetCoordinateSystemToNormalizedDisplay() + self._story_actor.GetPositionCoordinate().SetValue(0.99, 0.97) + self._story_actor.VisibilityOff() + self.ren.AddActor(self._story_actor) + + def _show_connections(self, source_idx: int, depth: int = 2, top_k: int = 15): + """Show connections from selected ROI. + + Always draws direct connections (depth=1) from the source ROI first, + then adds multi-hop connections from intermediate ROIs. + """ + # Clear old connections + for act in self._connection_actors: + self.ren.RemoveActor(act) + self._connection_actors.clear() + + connections = get_strongest_connections(self.A, source_idx, + top_k=top_k, depth=depth) + if not connections: + return + + # Separate by depth to ensure direct connections are always shown + depth1 = [c for c in connections if c["depth"] == 1] + depth2 = [c for c in connections if c["depth"] > 1] + + # Show up to 10 direct + 5 multi-hop + shown = depth1[:10] + depth2[:5] + if not shown: + return + + max_w = max(c["abs_weight"] for c in shown) + src_pos = self.centroids[source_idx] + + info_lines = [f"Selected: {self.roi_names[source_idx]}"] + state = self.brain_state_db.get(self.roi_names[source_idx]) + if state: + info_lines.append(f"State: {state[:80]}") + info_lines.append(f"Connections ({len(depth1)} direct, {len(depth2)} multi-hop):") + + for c in shown: + ti = c["target_idx"] + w = c["weight"] + # For depth-1: line goes from selected ROI to target + # For depth-2+: line goes from intermediate source to target + from_idx = c["source_idx"] + from_pos = self.centroids[from_idx] + to_pos = self.centroids[ti] + + # Create line + line = vtk.vtkLineSource() + line.SetPoint1(float(from_pos[0]), float(from_pos[1]), float(from_pos[2])) + line.SetPoint2(float(to_pos[0]), float(to_pos[1]), float(to_pos[2])) + + mapper = vtk.vtkPolyDataMapper() + mapper.SetInputConnection(line.GetOutputPort()) + + actor = vtk.vtkActor() + actor.SetMapper(mapper) + + # Color: green for positive, red for negative; dimmer for multi-hop + norm_w = c["abs_weight"] / max(max_w, 1e-9) + is_multihop = c["depth"] > 1 + if w > 0: + if is_multihop: + actor.GetProperty().SetColor(0.4, 0.7, 0.4) # dimmer green + else: + actor.GetProperty().SetColor(0.2, 0.9, 0.2) + else: + if is_multihop: + actor.GetProperty().SetColor(0.7, 0.4, 0.4) # dimmer red + else: + actor.GetProperty().SetColor(0.9, 0.2, 0.2) + + line_width = max(1.0, 4.0 * norm_w) if not is_multihop else max(1.0, 2.5 * norm_w) + actor.GetProperty().SetLineWidth(line_width) + actor.GetProperty().SetOpacity(max(0.3, norm_w * (0.7 if is_multihop else 1.0))) + + self.ren.AddActor(actor) + self._connection_actors.append(actor) + + # Highlight target ROI + if ti < len(self._roi_actors): + if w > 0: + self._roi_actors[ti].GetProperty().SetColor(0.2, 0.9, 0.2) + else: + self._roi_actors[ti].GetProperty().SetColor(0.9, 0.2, 0.2) + opacity = min(1.0, 0.5 + norm_w * 0.5) if not is_multihop else min(0.9, 0.3 + norm_w * 0.4) + self._roi_actors[ti].GetProperty().SetOpacity(opacity) + + depth_tag = f"d{c['depth']}" if is_multihop else " " + sign_tag = "inh" if w < 0 else "exc" + info_lines.append(f" {depth_tag}: {self.roi_names[ti]} " + f"w={w:+.4f} ({sign_tag})") + + self._sel_actor.SetInput("\n".join(info_lines)) + self.win.Render() + + def _clear_state_labels(self): + """Remove all 3D state text labels and propagation lines.""" + for act in self._state_label_actors: + self.ren.RemoveActor(act) + self._state_label_actors.clear() + for act in self._propagation_line_actors: + self.ren.RemoveActor(act) + self._propagation_line_actors.clear() + + @staticmethod + def _word_wrap(text: str, width: int = 45, max_lines: int = 20) -> str: + """Word-wrap text to fit in a VTK text actor.""" + lines = [] + for line in text.split("\n"): + while len(line) > width: + brk = line.rfind(" ", 0, width) + if brk <= 0: + brk = width + lines.append(line[:brk]) + line = line[brk:].lstrip() + lines.append(line) + return "\n".join(lines[:max_lines]) + + def _add_state_label(self, roi_idx: int, text: str, color=(1.0, 0.4, 0.4)): + """Add a small 3D text label centered on an ROI showing its updated state.""" + pos = self.centroids[roi_idx] + + actor = vtk.vtkBillboardTextActor3D() + # Truncate to keep labels compact + short = text[:60] + "..." if len(text) > 60 else text + actor.SetInput(short) + # Position directly at the ROI center — billboard text will face camera + actor.SetPosition(float(pos[0]), float(pos[1]), float(pos[2])) + tp = actor.GetTextProperty() + tp.SetColor(*color) + tp.SetFontSize(10) + tp.SetFontFamilyToCourier() + tp.SetBold(True) + tp.SetJustificationToCentered() + tp.SetVerticalJustificationToCentered() + self.ren.AddActor(actor) + self._state_label_actors.append(actor) + + # Highlight the ROI sphere in red + if roi_idx < len(self._roi_actors): + self._roi_actors[roi_idx].GetProperty().SetColor(*color) + self._roi_actors[roi_idx].GetProperty().SetOpacity(1.0) + + def _add_propagation_line(self, source_idx: int, target_idx: int, + color=(1.0, 0.3, 0.3)): + """Draw a line from source to target ROI during animated propagation.""" + p1 = self.centroids[source_idx] + p2 = self.centroids[target_idx] + + pts = vtk.vtkPoints() + pts.InsertNextPoint(float(p1[0]), float(p1[1]), float(p1[2])) + pts.InsertNextPoint(float(p2[0]), float(p2[1]), float(p2[2])) + + line = vtk.vtkLine() + line.GetPointIds().SetId(0, 0) + line.GetPointIds().SetId(1, 1) + + cells = vtk.vtkCellArray() + cells.InsertNextCell(line) + + pd = vtk.vtkPolyData() + pd.SetPoints(pts) + pd.SetLines(cells) + + mapper = vtk.vtkPolyDataMapper() + mapper.SetInputData(pd) + + actor = vtk.vtkActor() + actor.SetMapper(mapper) + actor.GetProperty().SetColor(*color) + actor.GetProperty().SetLineWidth(3.0) + actor.GetProperty().SetOpacity(0.8) + self.ren.AddActor(actor) + self._propagation_line_actors.append(actor) + + def _select_roi(self, idx: int): + """Select an ROI.""" + # Reset old selection + if self.selected_roi is not None and self.selected_roi < len(self._roi_actors): + self._roi_actors[self.selected_roi].GetProperty().SetColor(0.6, 0.6, 0.6) + self._roi_actors[self.selected_roi].GetProperty().SetOpacity(0.7) + + # Reset all ROIs + for act in self._roi_actors: + act.GetProperty().SetColor(0.6, 0.6, 0.6) + act.GetProperty().SetOpacity(0.7) + + self.selected_roi = idx + if idx < len(self._roi_actors): + self._roi_actors[idx].GetProperty().SetColor(1.0, 1.0, 0.0) + self._roi_actors[idx].GetProperty().SetOpacity(1.0) + + self._show_connections(idx, depth=self.propagation_depth, + top_k=self.propagation_top_k) + print(f"[select] ROI {idx}: {self.roi_names[idx]} " + f"(depth={self.propagation_depth}, top_k={self.propagation_top_k})") + + def run(self): + """Start the interactive visualizer.""" + iren = vtk.vtkRenderWindowInteractor() + iren.SetRenderWindow(self.win) + style = vtk.vtkInteractorStyleTrackballCamera() + iren.SetInteractorStyle(style) + + def on_click(obj, ev): + x, y = obj.GetEventPosition() + if self.picker.Pick(x, y, 0, self.ren) <= 0: + return + # Find nearest ROI + px, py, pz = self.picker.GetPickPosition() + pos = np.array([px, py, pz], np.float32) + dists = np.linalg.norm(self.centroids - pos, axis=1) + nearest = int(np.argmin(dists)) + if dists[nearest] < 10.0: # threshold + self._anim_playing = False + self._summary_actor.VisibilityOff() + self._story_actor.VisibilityOff() + self._clear_state_labels() + self._select_roi(nearest) + + def on_key(obj, ev): + key = obj.GetKeySym() + key_lower = key.lower() if key else "" + shift = bool(obj.GetShiftKey()) + + if key_lower == "escape": + obj.TerminateApp() + + elif key_lower == "p" and not shift: + # Perturb selected ROI (synchronous — VTK freezes during GPT) + self._anim_playing = False + if self.selected_roi is None: + print("[perturb] Select an ROI first (click).") + return + name = self.roi_names[self.selected_roi] + current_state = self.brain_state_db.get(name) + + self._sel_actor.SetInput(f"Generating perturbation options\nfor {name}...\n(window will freeze briefly)") + self.win.Render() + + print(f"\n[perturb] Region: {name}") + if current_state: + print(f" Current state: {current_state}") + print(f" Fetching perturbation options from GPT...") + sys.stdout.flush() + + try: + proposals = self.brain_state_db.propose_perturbations(name) + except Exception as e: + print(f"[perturb] GPT proposal failed: {e}") + proposals = [ + "Heightened activity in this region", + "Suppressed activity in this region", + "Shift to an alternative processing mode", + "Disrupted connectivity with downstream regions", + ] + + # Show options in VTK and console + lines = [f"Perturbation options for:", name[:60], ""] + for i, p in enumerate(proposals, 1): + lines.append(f" {i}. {p[:55]}") + lines.append(f" 5. (Custom — type in console)") + lines.append("") + lines.append("Press 1-5 in VTK window") + self._sel_actor.SetInput("\n".join(lines)) + self.win.Render() + + print(f"\n Perturbation options for {name}:") + for i, p in enumerate(proposals, 1): + print(f" {i}. {p}") + print(f" 5. (Write your own)") + print(f" >>> Press 1-5 in the VTK window <<<\n") + sys.stdout.flush() + + self._perturb_proposals = proposals + self._perturb_region = name + self._perturb_waiting = True + + elif key_lower in ("1", "2", "3", "4") and self._perturb_waiting: + # User picked a perturbation option (synchronous) + idx = int(key_lower) - 1 + desc = self._perturb_proposals[idx] + self._perturb_waiting = False + self._perturb_proposals = None + name = self._perturb_region + print(f"[perturb] Applying option {key_lower}: {desc}") + self._sel_actor.SetInput(f"Applying perturbation\nto {name}...\n(window will freeze briefly)") + self.win.Render() + + try: + new_state = self.brain_state_db.alter_region_state( + name, desc, skip_validation=True) + print(f"[perturb] {name} -> {new_state}") + self._sel_actor.SetInput(f"Perturbed: {name}\n{new_state[:80]}") + except Exception as e: + print(f"[perturb] Error: {e}") + self._sel_actor.SetInput(f"Perturbation failed: {e}") + self.win.Render() + + elif key_lower == "5" and self._perturb_waiting: + # Custom perturbation — synchronous console input + self._perturb_waiting = False + self._perturb_proposals = None + name = self._perturb_region + self._sel_actor.SetInput(f"Type perturbation in console\nthen press Enter") + self.win.Render() + + print(f" >>> Type your custom perturbation in the console <<<") + sys.stdout.flush() + desc = input(" Enter your perturbation: ").strip() + if not desc: + print("[perturb] Cancelled.") + return + + print(f"[perturb] Applying: {desc}") + self._sel_actor.SetInput(f"Applying perturbation\nto {name}...") + self.win.Render() + + try: + new_state = self.brain_state_db.alter_region_state( + name, desc, skip_validation=True) + print(f"[perturb] {name} -> {new_state}") + self._sel_actor.SetInput(f"Perturbed: {name}\n{new_state[:80]}") + except Exception as e: + print(f"[perturb] Error: {e}") + self._sel_actor.SetInput(f"Perturbation failed: {e}") + self.win.Render() + + elif key_lower == "p" and shift: + # Propagate through network (synchronous) + if self.selected_roi is None: + print("[propagate] Select an ROI first.") + return + if not self.brain_state_db.has_states(): + print("[propagate] Initialize states first (s).") + return + + source_name = self.roi_names[self.selected_roi] + source_idx = self.selected_roi + connections = get_strongest_connections( + self.A, source_idx, + top_k=self.propagation_top_k, + depth=self.propagation_depth + ) + + self._sel_actor.SetInput(f"Propagating from {source_name}...\n(window will freeze briefly)") + self._clear_state_labels() + self._summary_actor.VisibilityOff() + self._story_actor.VisibilityOff() + self.win.Render() + + print(f"[propagate] From {source_name} to " + f"{len(connections)} regions (graph-based)...") + sys.stdout.flush() + + # Build name->index map and target->source map for visualization + name_to_idx = {n: i for i, n in enumerate(self.roi_names)} + # Map target_name -> source_idx from the connections list + target_source_map = {} + for c in connections: + tname = self.roi_names[c["target_idx"]] + target_source_map[tname] = c["source_idx"] + + # Collect propagation steps for looped animation + self._anim_steps = [] # list of (from_idx, to_idx, state_text, depth) + + def _render_update(msg): + """Called per-region during propagation.""" + print(f" {msg}") + sys.stdout.flush() + if ": " in msg: + parts = msg.strip().split(": ", 1) + target_name = parts[0].strip() + new_state = parts[1].strip() + ti = name_to_idx.get(target_name) + if ti is not None: + # Draw line from the actual source (not always the original) + from_idx = target_source_map.get(target_name, source_idx) + # Find depth for color + depth = 1 + for c in connections: + if c["target_idx"] == ti: + depth = c.get("depth", 1) + break + color = (1.0, 0.3, 0.3) if depth == 1 else (1.0, 0.6, 0.2) + self._add_propagation_line(from_idx, ti, color=color) + self._add_state_label(ti, new_state, color=color) + self.win.Render() + # Store for animation loop + self._anim_steps.append((from_idx, ti, new_state, depth)) + + try: + for c in connections: + c["target_name"] = self.roi_names[c["target_idx"]] + + updates = self.brain_state_db.propagate_through_graph( + source_name, connections, + A=self.A, roi_names=self.roi_names, + callback=_render_update + ) + if updates: + summary = self.brain_state_db.summarize_changes( + updates, source_name + ) + story = self.brain_state_db.generate_flow_story( + updates, source_name, connections=connections + ) + print(f"\n--- PROPAGATION SUMMARY ---") + print(summary) + print(f"\n--- INFORMATION FLOW STORY ---") + print(story) + print("--- END ---\n") + + # Show summary in top-left + self._summary_actor.SetInput( + self._word_wrap(f"PROPAGATION SUMMARY\n{summary}", 45, 20)) + self._summary_actor.VisibilityOn() + + # Show story in top-right + self._story_actor.SetInput( + self._word_wrap(f"INFORMATION FLOW\n{story}", 45, 20)) + self._story_actor.VisibilityOn() + + # Clear yellow status text + self._sel_actor.SetInput("") + + # Start looped animation + self._anim_source_idx = source_idx + self._anim_frame = 0 + self._anim_playing = True + else: + print("[propagate] No changes.") + except Exception as e: + print(f"[propagate] Error: {e}") + import traceback + traceback.print_exc() + self.win.Render() + + elif key_lower == "s": + # Initialize states (synchronous) + region_names = self.roi_names + self._sel_actor.SetInput("Enter brain state in console...") + self.win.Render() + print("[states] Enter global brain state (type in console):") + sys.stdout.flush() + + gs = input(" > ").strip() + self._sel_actor.SetInput(f"Initializing states...\n(window will freeze)") + self.win.Render() + + try: + result = self.brain_state_db.initialize_from_global( + gs, region_names, + callback=lambda m: print(f" {m}") + ) + print(f"[states] {result}") + self._sel_actor.SetInput("States initialized.") + except Exception as e: + print(f"[states] Error: {e}") + self._sel_actor.SetInput(f"State init failed: {e}") + self.win.Render() + + elif key_lower == "r": + self.brain_state_db.clear() + print("[states] Cleared all states.") + + elif key_lower in ("plus", "equal"): + self.connection_threshold = min(0.5, self.connection_threshold * 1.5) + print(f"[threshold] {self.connection_threshold:.3f}") + if self.selected_roi is not None: + self._show_connections(self.selected_roi, + depth=self.propagation_depth, + top_k=self.propagation_top_k) + + elif key_lower in ("minus", "underscore"): + self.connection_threshold = max(0.001, self.connection_threshold / 1.5) + print(f"[threshold] {self.connection_threshold:.3f}") + if self.selected_roi is not None: + self._show_connections(self.selected_roi, + depth=self.propagation_depth, + top_k=self.propagation_top_k) + + elif key_lower == "bracketright": + # ] = increase propagation depth + self.propagation_depth = min(6, self.propagation_depth + 1) + print(f"[depth] propagation depth = {self.propagation_depth}") + self._sel_actor.SetInput( + f"Propagation depth: {self.propagation_depth}\n" + f"(top_k={self.propagation_top_k} per level)") + if self.selected_roi is not None: + self._show_connections(self.selected_roi, + depth=self.propagation_depth, + top_k=self.propagation_top_k) + self.win.Render() + + elif key_lower == "bracketleft": + # [ = decrease propagation depth + self.propagation_depth = max(1, self.propagation_depth - 1) + print(f"[depth] propagation depth = {self.propagation_depth}") + self._sel_actor.SetInput( + f"Propagation depth: {self.propagation_depth}\n" + f"(top_k={self.propagation_top_k} per level)") + if self.selected_roi is not None: + self._show_connections(self.selected_roi, + depth=self.propagation_depth, + top_k=self.propagation_top_k) + self.win.Render() + + elif key_lower == "period": + # . = increase top_k (more connections per level) + self.propagation_top_k = min(50, self.propagation_top_k + 5) + print(f"[top_k] connections per level = {self.propagation_top_k}") + if self.selected_roi is not None: + self._show_connections(self.selected_roi, + depth=self.propagation_depth, + top_k=self.propagation_top_k) + self.win.Render() + + elif key_lower == "comma": + # , = decrease top_k + self.propagation_top_k = max(3, self.propagation_top_k - 5) + print(f"[top_k] connections per level = {self.propagation_top_k}") + if self.selected_roi is not None: + self._show_connections(self.selected_roi, + depth=self.propagation_depth, + top_k=self.propagation_top_k) + self.win.Render() + + def on_anim_timer(_o, _e): + """Looped propagation animation — show signal traveling step by step.""" + if not self._anim_playing or not self._anim_steps: + return + + frame = self._anim_frame + steps = self._anim_steps + total_frames = len(steps) + 6 # steps + pause frames at end + + if frame == 0: + # Clear previous animation visuals + self._clear_state_labels() + # Show source ROI highlighted + if self._anim_source_idx < len(self._roi_actors): + self._roi_actors[self._anim_source_idx].GetProperty().SetColor(1.0, 1.0, 0.0) + self._roi_actors[self._anim_source_idx].GetProperty().SetOpacity(1.0) + + if frame < len(steps): + from_idx, to_idx, state_text, depth = steps[frame] + color = (1.0, 0.3, 0.3) if depth == 1 else (1.0, 0.6, 0.2) + self._add_propagation_line(from_idx, to_idx, color=color) + self._add_state_label(to_idx, state_text, color=color) + # Highlight target ROI + if to_idx < len(self._roi_actors): + self._roi_actors[to_idx].GetProperty().SetColor(*color) + self._roi_actors[to_idx].GetProperty().SetOpacity(1.0) + + self._anim_frame = frame + 1 + if self._anim_frame >= total_frames: + # Reset ROI colors and loop + for act in self._roi_actors: + act.GetProperty().SetColor(0.6, 0.6, 0.6) + act.GetProperty().SetOpacity(0.7) + self._anim_frame = 0 + + self.win.Render() + + iren.Initialize() + iren.AddObserver("LeftButtonPressEvent", on_click) + iren.AddObserver("KeyPressEvent", on_key) + iren.AddObserver("TimerEvent", on_anim_timer) + iren.CreateRepeatingTimer(500) # animation tick every 500ms + self.win.Render() + + print("\n=== rDCIM Propagation Visualizer ===") + print(f" ROIs: {self.R}") + print(f" Max |weight|: {self.max_weight:.4f}") + print(f" Click ROI to select | p perturb | Shift+P propagate") + print(f" s init states | r reset | +/- threshold") + print(f" [/] depth ({self.propagation_depth}) | ,/. top_k ({self.propagation_top_k}) | Esc quit") + print() + + iren.Start() + + +def main(): + _ensure_ssl() + try: + from dotenv import load_dotenv + load_dotenv(PROJECT_ROOT / ".env") + except ImportError: + pass + + ap = argparse.ArgumentParser(description="rDCIM Propagation Visualizer") + ap.add_argument("--rdcim", type=Path, default=DEFAULT_RDCIM, + help="rDCIM matrix .npy file") + ap.add_argument("--centroids", type=Path, default=DEFAULT_CENTROIDS, + help="ROI centroid positions .npy file") + ap.add_argument("--global-state", type=str, default="", + help="Initialize brain states with this global state") + ap.add_argument("--perturb", type=int, default=None, + help="ROI index to perturb on startup") + ap.add_argument("--perturbation", type=str, default="", + help="Perturbation description") + ap.add_argument("--hq", action="store_true", + help="Use high-quality GPT model (gpt-5.4) instead of gpt-5.4-mini") + ap.add_argument("--debug", action="store_true", + help="Print full LLM prompts to console before each call") + ap.add_argument("--extra-parcellation", type=Path, default=None, + help="Path to NIfTI parcellation file for finer subregion labels") + ap.add_argument("--extra-parcellation-labels", type=Path, default=None, + help="JSON label map for extra parcellation") + args = ap.parse_args() + + model = "gpt-5.4" if args.hq else "gpt-5.4-mini" + print(f"[config] LLM model: {model}") + + A = load_rdcim(args.rdcim) + centroids = load_centroids(args.centroids) + + if A.shape[0] != centroids.shape[0]: + print(f"[warning] matrix ({A.shape[0]}) != centroids ({centroids.shape[0]})") + n = min(A.shape[0], centroids.shape[0]) + A = A[:n, :n] + centroids = centroids[:n] + + roi_names = get_roi_names(A.shape[0]) + + # Map ROI names to actual Allen atlas regions + print("[init] Mapping ROIs to anatomical regions...") + roi_names = map_rois_to_regions(centroids, roi_names) + + brain_db = BrainStateDB(DEFAULT_STATE_FILE, model=model, debug=args.debug) + + if args.global_state: + print(f"[init] Initializing brain states: {args.global_state}") + brain_db.initialize_from_global(args.global_state, roi_names) + + viz = RDCIMVisualizer(A, centroids, roi_names, brain_db) + + if args.perturb is not None and args.perturbation: + if 0 <= args.perturb < len(roi_names): + brain_db.alter_region_state(roi_names[args.perturb], args.perturbation) + + viz.run() + + +if __name__ == "__main__": + main() diff --git a/examples/roi_flow_mode.py b/examples/roi_flow_mode.py new file mode 100644 index 0000000000000000000000000000000000000000..637f5c9cd84da207d23faab1ca46deb73a50f6f5 --- /dev/null +++ b/examples/roi_flow_mode.py @@ -0,0 +1,1434 @@ +#!/usr/bin/env python +"""ROI Flow Mode — manifold flow visualization with ROI activation panel. + +Single-window visualization with two viewports: + Left: MDN particle flow through a learned neural manifold + Right: ROI activation spheres showing corresponding brain region activity + +Place a probe in the manifold (follows flow or manual path), freeze it, +and ask the LLM to interpret what the resulting ROI contribution shift means. +After LLM analysis, animated particles flow between donor/receiver ROIs. + +Usage: + python examples/roi_flow_mode.py [--hq] [--debug] + +Controls: + G then click — place probe in manifold (follows flow) + M then click — manual path mode (click to extend path) + Shift+G — freeze probe, compute ROI delta, ask LLM + V — toggle ROI anim mode (path ↔ particles) + 1 / 2 — save current path as Path A / B + D — compare Path A vs Path B (cyan=A, magenta=B) + C — clear all + +/- — speed scale + Q/Esc — quit + +Data: + Run `python scripts/download_roi_flow_data.py` first to download all + required data from HuggingFace. +""" + +import argparse +import json +import sys +import threading +from pathlib import Path + +import numpy as np +import vtk +from vtkmodules.util.numpy_support import numpy_to_vtk +from vtkmodules.vtkInteractionStyle import vtkInteractorStyleTrackballCamera +from vtkmodules.vtkFiltersGeneral import vtkSplineFilter +from vtkmodules.vtkFiltersCore import vtkTubeFilter + +# Add project root to path +PROJECT_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + +from src.field_loader import load_field, TriLinearSampler +from src.colormaps import turbo_rgb01 +from src.roi_flow import ManifoldToROIKNN, ROIFlowAnalyzer, ROIFlowLLM +# Reuse visualization utilities from src/main.py +from src.main import build_cloud, add_window_legend, VtkTextOverlay, wrap_inside + +# Default paths — all ROI flow data lives in data/roi_flow/ +DATA_DIR = PROJECT_ROOT / "data" +ROI_FLOW_DIR = DATA_DIR / "roi_flow" + +DEFAULT_META = ROI_FLOW_DIR / "mdn_universal_raw_grid64_meta.json" +DEFAULT_OOS = ROI_FLOW_DIR / "universal_soul_2sdm_rest_points.ply" +DEFAULT_PROBE_EMBED = ROI_FLOW_DIR / "probe_embed.npy" +DEFAULT_PROBE_ROI = ROI_FLOW_DIR / "probe_roi.npy" +DEFAULT_PROBE_ROI_CENTERS = ROI_FLOW_DIR / "probe_roi_centers.npy" + + +# --------------------------------------------------------------------------- +# Utilities +# --------------------------------------------------------------------------- + +def _load_points_any(path: Path) -> np.ndarray: + """Load points from .npy, .ply, .obj, .stl files using VTK.""" + if path.suffix == ".npy": + return np.load(str(path)).astype(np.float32) + readers = { + ".ply": vtk.vtkPLYReader, ".obj": vtk.vtkOBJReader, + ".stl": vtk.vtkSTLReader, ".vtk": vtk.vtkPolyDataReader, + } + reader_cls = readers.get(path.suffix) + if reader_cls is None: + raise ValueError(f"Unsupported format: {path.suffix}") + reader = reader_cls() + reader.SetFileName(str(path)) + reader.Update() + from vtkmodules.util.numpy_support import vtk_to_numpy + return vtk_to_numpy(reader.GetOutput().GetPoints().GetData()).astype(np.float32) + + +def _map_roi_to_brain_regions(roi_centers: np.ndarray) -> list[str]: + """Map ROI centroids to Allen brain regions.""" + try: + from src.mesh_overlay import FlowMeshOverlay + alignment_file = DATA_DIR / "brain_alignment.json" + mesh_dir = DATA_DIR / "meshes" + if not alignment_file.exists() or not mesh_dir.exists(): + raise FileNotFoundError("Brain meshes not available") + + ren = vtk.vtkRenderer() + win = vtk.vtkRenderWindow() + win.SetOffScreenRendering(1) + win.AddRenderer(ren) + + overlay = FlowMeshOverlay(ren=ren, win=win, mesh_dir=mesh_dir, + alignment_file=alignment_file) + grid_cache = DATA_DIR / "label_grid_cache.npz" + if not overlay.load_label_grid(grid_cache): + overlay.build_label_grid() + overlay.save_label_grid(grid_cache) + + names = [] + for i, pos in enumerate(roi_centers): + key = overlay.get_region_at_point(pos) + if key is None: + key = overlay.find_nearest_region(pos, search_radius=6) + if key is not None: + region_name = overlay.get_region_name(key) + hemi = "left" if pos[0] < 0 else "right" + ap = "anterior" if pos[1] > 0 else "posterior" + names.append(f"ROI_{i} ({region_name}, {hemi}, {ap})") + else: + hemi = "left" if pos[0] < 0 else "right" + names.append(f"ROI_{i} ({hemi})") + mapped = sum(1 for n in names if ", " in n) + print(f"[roi-map] Mapped {mapped}/{len(names)} ROIs to brain regions") + return names + except Exception as e: + print(f"[roi-map] Could not map ROIs to brain regions: {e}") + names = [] + for i, pos in enumerate(roi_centers): + hemi = "left" if pos[0] < 0 else "right" + ap = "anterior" if pos[1] > 0 else "posterior" + si = "superior" if pos[2] > 0 else "inferior" + names.append(f"ROI_{i} ({hemi}, {ap}, {si})") + return names + + +def _build_spline_trail(pts_array: np.ndarray, diag: float): + """Build a smooth yellow spline+tube actor from a sequence of points.""" + if pts_array is None or len(pts_array) < 2: + return None + vpts = vtk.vtkPoints() + vpts.SetData(numpy_to_vtk(pts_array.astype(np.float32), deep=True)) + pl = vtk.vtkPolyLine() + pl.GetPointIds().SetNumberOfIds(len(pts_array)) + for i in range(len(pts_array)): + pl.GetPointIds().SetId(i, i) + lines = vtk.vtkCellArray() + lines.InsertNextCell(pl) + poly = vtk.vtkPolyData() + poly.SetPoints(vpts) + poly.SetLines(lines) + + spl = vtkSplineFilter() + spl.SetInputData(poly) + spl.SetSubdivideToLength() + spl.SetLength(max(diag * 0.01, 1e-6)) + spl.Update() + + tube = vtkTubeFilter() + tube.SetInputConnection(spl.GetOutputPort()) + tube.SetNumberOfSides(12) + tube.SetRadius(diag * 0.004) + tube.CappingOn() + tube.Update() + + mapper = vtk.vtkPolyDataMapper() + mapper.SetInputConnection(tube.GetOutputPort()) + actor = vtk.vtkActor() + actor.SetMapper(mapper) + actor.GetProperty().SetColor(1.0, 1.0, 0.0) + actor.GetProperty().SetOpacity(0.85) + actor.GetProperty().LightingOff() + return actor + + +# --------------------------------------------------------------------------- +# ROI Panel (right side of split window) +# --------------------------------------------------------------------------- + +class ROIPanel: + """Renders ROI spheres colored/sized by activation (white-center bicolor).""" + + # Original bicolor scheme: white center, orange positive, blue negative + POS_COLOR = np.array([255, 120, 0], np.float64) # orange + NEG_COLOR = np.array([0, 100, 255], np.float64) # blue + WHITE = np.array([255, 255, 255], np.float64) + GRAY = np.array([200, 200, 200], np.float64) / 255.0 + + def __init__(self, ren: vtk.vtkRenderer, centers: np.ndarray, + names: list[str]): + self.ren = ren + self.centers = centers.astype(np.float64) + self.names = names + self.n_rois = len(names) + self.base_radius = 0.6 + self.radius_scale = 1.0 + self._spheres: list[vtk.vtkActor] = [] + self._setup() + + def _setup(self): + for i in range(self.n_rois): + sphere = vtk.vtkSphereSource() + sphere.SetCenter(*self.centers[i]) + sphere.SetRadius(self.base_radius) + sphere.SetPhiResolution(16) + sphere.SetThetaResolution(16) + sphere.Update() + mapper = vtk.vtkPolyDataMapper() + mapper.SetInputConnection(sphere.GetOutputPort()) + actor = vtk.vtkActor() + actor.SetMapper(mapper) + actor.GetProperty().SetColor(*self.GRAY) + actor.GetProperty().SetOpacity(0.7) + self.ren.AddActor(actor) + self._spheres.append(actor) + + @staticmethod + def _bicolor(value: float, abs_max: float): + """White-center bicolor: white->orange (positive), white->blue (negative).""" + if abs_max < 1e-12: + return ROIPanel.GRAY + t = np.clip(abs(value) / abs_max, 0.0, 1.0) + if value >= 0: + rgb = ROIPanel.WHITE * (1.0 - t) + ROIPanel.POS_COLOR * t + else: + rgb = ROIPanel.WHITE * (1.0 - t) + ROIPanel.NEG_COLOR * t + return rgb / 255.0 + + def update_values(self, values: np.ndarray): + if values is None or len(values) != self.n_rois: + return + av = np.abs(values) + abs_max = float(np.percentile(av, 97)) + 1e-8 + for i in range(self.n_rois): + v = float(values[i]) + color = self._bicolor(v, abs_max) + radius = self.base_radius + self.radius_scale * min(abs(v) / abs_max, 1.0) + self._spheres[i].GetProperty().SetColor(*color) + mapper = self._spheres[i].GetMapper() + src = vtk.vtkSphereSource() + src.SetCenter(*self.centers[i]) + src.SetRadius(radius) + src.SetPhiResolution(16) + src.SetThetaResolution(16) + src.Update() + mapper.SetInputData(src.GetOutput()) + mapper.Update() + + def dim_spheres(self, opacity: float = 0.15): + for s in self._spheres: + s.GetProperty().SetOpacity(opacity) + + def restore_spheres(self, opacity: float = 0.7): + for s in self._spheres: + s.GetProperty().SetOpacity(opacity) + + def reset_colors(self): + for i in range(self.n_rois): + self._spheres[i].GetProperty().SetColor(*self.GRAY) + self._spheres[i].GetProperty().SetOpacity(0.7) + + +# --------------------------------------------------------------------------- +# ROI Flow Dots — animated particles flowing between donor/receiver ROIs +# --------------------------------------------------------------------------- + +class ROIFlowDots: + """Particles flowing from donor (negative delta) to receiver (positive delta) ROIs. + + Matches the original simulate_mdn_flow.py behavior: 120k particles, + turbo colormap, endpoint fade, continuous emission from donor→receiver pairs. + """ + + def __init__(self, ren: vtk.vtkRenderer, roi_centers: np.ndarray, + max_particles: int = 120000): + self.ren = ren + self.C = roi_centers.astype(np.float32) + self.Nmax = max_particles + self.rng = np.random.default_rng(42) + + self.pos = np.zeros((self.Nmax, 3), np.float32) + self.p0 = np.zeros((self.Nmax, 3), np.float32) + self.dest = np.zeros((self.Nmax, 3), np.float32) + self.alive = np.zeros(self.Nmax, bool) + self.age = np.zeros(self.Nmax, np.float32) + self.life = np.ones(self.Nmax, np.float32) + self.speed = np.zeros(self.Nmax, np.float32) # for colormap + + self.emitter_on = False + self._neg_idx = np.array([], np.int32) + self._pos_idx = np.array([], np.int32) + self._pairs_probs = None + self._emit_rate = 2000.0 + self._emit_accum = 0.0 + self._dt = 1.0 / 60.0 + # Endpoint fade parameters (matching original) + self._fade_start = 0.08 # fade in over first 8% + self._fade_end = 0.08 # fade out over last 8% + # Burst mode (on by default — short sharp bursts with visible gap) + self.burst_mode = True + self.burst_period = 0.6 # seconds per cycle (fast) + self.burst_emit_frac = 0.25 # emit during first 25% — short burst, long gap + self._burst_timer = 0.0 + self.capture_accel = 2.5 # fast capture so particles clear out during gap + # Mid-segment fade: make particles transparent in the middle of their journey + # so you can clearly see source and destination endpoints + self._mid_fade = True + + # Use build_cloud from src/main.py + init_rgba = np.zeros((self.Nmax, 4), np.uint8) + self._pts, self._colors, self._pd, self.actor = build_cloud( + np.zeros((self.Nmax, 3), np.float32), rgba=init_rgba) + self.actor.GetProperty().SetPointSize(1.2) + self.actor.VisibilityOff() + self.ren.AddActor(self.actor) + + def start_from_delta(self, delta: np.ndarray, top_frac: float = 0.15, + emit_rate: float = 2000.0): + v = delta.astype(np.float64) + k = max(1, int(len(v) * top_frac)) + idx_strong = np.argsort(-np.abs(v))[:k] + v_str = v[idx_strong] + eps = 1e-8 + self._pos_idx = idx_strong[v_str > eps] + self._neg_idx = idx_strong[v_str < -eps] + + if len(self._pos_idx) == 0 or len(self._neg_idx) == 0: + return + + p_don = np.abs(v[self._neg_idx]) + p_don /= p_don.sum() + p_recv = np.abs(v[self._pos_idx]) + p_recv /= p_recv.sum() + pairs = np.outer(p_don, p_recv).ravel() + pairs /= pairs.sum() + self._pairs_probs = pairs + + # Scale emit rate by delta magnitude (like original) + l1 = float(np.sum(np.abs(v))) + self._emit_rate = max(100.0, min(emit_rate * max(l1, 0.1), 8000.0)) + + self.alive[:] = False + self._emit_accum = 0.0 + self.emitter_on = True + self.actor.VisibilityOn() + print(f"[roi-flow] Started: {len(self._neg_idx)} donors -> " + f"{len(self._pos_idx)} receivers, rate={self._emit_rate:.0f}/s") + + def tick(self): + if not self.emitter_on: + return + + # Burst phase: emit only during first fraction of each cycle + if self.burst_mode: + self._burst_timer = (self._burst_timer + self._dt) % max(self.burst_period, 1e-6) + emit_phase = (self._burst_timer / max(self.burst_period, 1e-6)) < self.burst_emit_frac + else: + emit_phase = True + + # Advance alive particles + idx = np.nonzero(self.alive)[0] + if len(idx) > 0: + # Accelerate during capture phase of burst + speed_mul = 1.0 + if self.burst_mode and not emit_phase: + speed_mul = self.capture_accel + self.age[idx] += self._dt * speed_mul + t = np.clip(self.age[idx] / self.life[idx], 0.0, 1.0) + te = t * t * (3.0 - 2.0 * t) # smoothstep easing + seg = self.dest[idx] - self.p0[idx] + self.pos[idx] = self.p0[idx] + seg * te[:, None] + self.alive[idx[t >= 1.0]] = False + + # Spawn new particles (only during emit phase) + Nd, Nr = len(self._neg_idx), len(self._pos_idx) + if Nd > 0 and Nr > 0: + self._emit_accum += self._emit_rate * self._dt + n_new = int(self._emit_accum) + if emit_phase and n_new > 0: + self._emit_accum -= n_new + free = np.nonzero(~self.alive)[0] + use = free[:n_new] + if len(use) > 0: + choice = self.rng.choice(Nd * Nr, size=len(use), + replace=True, p=self._pairs_probs) + src_roi = self._neg_idx[choice // Nr] + dst_roi = self._pos_idx[choice % Nr] + self.p0[use] = self.C[src_roi] + self.dest[use] = self.C[dst_roi] + self.life[use] = self.rng.uniform(0.8, 1.8, size=len(use)).astype(np.float32) + self.age[use] = 0.0 + self.alive[use] = True + seg_len = np.linalg.norm(self.dest[use] - self.p0[use], axis=1) + self.speed[use] = seg_len / (self.life[use] + 1e-8) + elif not emit_phase: + # Still accumulate but don't spend — creates burst on next emit phase + pass + + self._update_display() + + def _update_display(self): + idx = np.nonzero(self.alive)[0] + P = np.zeros((self.Nmax, 3), np.float32) + rgba = np.zeros((self.Nmax, 4), np.uint8) + + if len(idx) > 0: + P[idx] = self.pos[idx] + # Color from precomputed speed via turbo colormap + spd = self.speed[idx] + s_max = float(np.percentile(spd, 97)) if len(spd) > 0 else 1.0 + t = np.clip(spd / max(s_max, 1e-8), 0.0, 1.0) + rgb = turbo_rgb01(t) + + # Endpoint fade + mid-segment dip + # Particles are opaque near source and destination but fade in the + # middle of their journey so you can clearly see where they come + # from and where they go. + prog = np.clip(self.age[idx] / self.life[idx], 0.0, 1.0) + fs, fe = self._fade_start, self._fade_end + + # Base endpoint fade + alpha = np.where(prog < fs, prog / max(fs, 1e-6), + np.where(prog > (1.0 - fe), + (1.0 - prog) / max(fe, 1e-6), 1.0)) + + # Mid-segment transparency dip (U-shaped alpha along journey) + if getattr(self, '_mid_fade', False): + # mid_alpha: 1.0 at endpoints, dips to 0.3 at center + mid = 0.5 + spread = 0.35 # width of the dip + dist_from_mid = np.abs(prog - mid) / spread + mid_alpha = np.clip(0.3 + 0.7 * dist_from_mid, 0.3, 1.0) + alpha = alpha * mid_alpha + + alpha = (np.clip(alpha, 0.0, 1.0) * 220).astype(np.uint8) + + rgba[idx, :3] = rgb + rgba[idx, 3] = alpha + + self._pts.SetData(numpy_to_vtk(P, deep=True)) + self._colors.DeepCopy(numpy_to_vtk(rgba, deep=True)) + self._colors.Modified() + self._pd.Modified() + + def stop(self): + self.emitter_on = False + self.alive[:] = False + self.actor.VisibilityOff() + + def is_active(self): + return self.emitter_on + + +# --------------------------------------------------------------------------- +# ROI Path Animation — animate ROI activation along the probe path +# --------------------------------------------------------------------------- + +class ROIPathAnimation: + """Snap between START and END ROI activation states — no intermediates. + + Fast toggle between the brain state at the beginning vs end of the path. + 200 most dynamic ROIs are animated; the rest stay dim gray. + Active ROIs are vivid turbo-colored and blow up to 3.5× base radius. + Quiet ROIs shrink to near-invisible gray dots. + """ + + N_TOP = 200 # number of ROIs to animate + TOGGLE_TICKS = 40 # ticks to hold each state before flipping + RADIUS_MIN_FRAC = 0.3 # quiet ROI radius multiplier + RADIUS_MAX_FRAC = 3.5 # loudest ROI radius multiplier + + def __init__(self, roi_panel: ROIPanel, knn: 'ManifoldToROIKNN'): + self.panel = roi_panel + self.knn = knn + self.active = False + self._start_vals: np.ndarray | None = None # (n_rois,) + self._end_vals: np.ndarray | None = None # (n_rois,) + self._top_mask: np.ndarray | None = None # (n_rois,) bool + self._showing_end = False + self._tick_counter = 0 + self._abs_max_start = 1.0 + self._abs_max_end = 1.0 + + # ----- resampling helper (also used by ROIPathCompare) ----- + @staticmethod + def _resample_path(pts: np.ndarray, n: int) -> np.ndarray: + diffs = np.linalg.norm(np.diff(pts, axis=0), axis=1) + cum = np.concatenate([[0.0], np.cumsum(diffs)]) + total_len = cum[-1] + if total_len < 1e-9: + return pts[:1] + sample_dists = np.linspace(0, total_len, n) + out = np.zeros((n, 3), np.float32) + for i, sd in enumerate(sample_dists): + idx = np.clip(np.searchsorted(cum, sd, side='right') - 1, + 0, len(pts) - 2) + seg_len = cum[idx + 1] - cum[idx] + t = (sd - cum[idx]) / max(seg_len, 1e-9) + out[i] = pts[idx] * (1 - t) + pts[idx + 1] * t + return out + + def build_from_path(self, path_points: list[np.ndarray], + n_samples: int = 60): + """Query ROI values at path start and end.""" + if len(path_points) < 2: + return + + pts = np.array(path_points, dtype=np.float32) + start_pt = pts[0] + end_pt = pts[-1] + + start_vals = self.knn.query(start_pt) + end_vals = self.knn.query(end_pt) + + # Select top N_TOP ROIs by absolute difference start→end + diff = np.abs(end_vals - start_vals) + n_top = min(self.N_TOP, self.panel.n_rois) + top_indices = np.argsort(-diff)[:n_top] + mask = np.zeros(self.panel.n_rois, bool) + mask[top_indices] = True + + self._start_vals = start_vals + self._end_vals = end_vals + self._top_mask = mask + self._abs_max_start = float(np.percentile(np.abs(start_vals[mask]), 97)) + 1e-8 + self._abs_max_end = float(np.percentile(np.abs(end_vals[mask]), 97)) + 1e-8 + self._showing_end = False + self._tick_counter = 0 + self.active = True + + # Dim non-selected ROIs immediately + for i in range(self.panel.n_rois): + if not mask[i]: + self.panel._spheres[i].GetProperty().SetColor(0.3, 0.3, 0.32) + self.panel._spheres[i].GetProperty().SetOpacity(0.06) + mapper = self.panel._spheres[i].GetMapper() + src = vtk.vtkSphereSource() + src.SetCenter(*self.panel.centers[i]) + src.SetRadius(self.panel.base_radius * 0.25) + src.SetPhiResolution(6) + src.SetThetaResolution(6) + src.Update() + mapper.SetInputData(src.GetOutput()) + mapper.Update() + + # Apply start state immediately + self._apply_state(False) + + print(f"[roi-anim] Start/end toggle: {n_top} active ROIs, " + f"flip every {self.TOGGLE_TICKS} ticks") + + def _apply_state(self, show_end: bool): + """Snap all selected ROIs to start or end state.""" + vals = self._end_vals if show_end else self._start_vals + abs_max = self._abs_max_end if show_end else self._abs_max_start + + for i in range(self.panel.n_rois): + if not self._top_mask[i]: + continue + + v = float(vals[i]) + intensity = min(abs(v) / abs_max, 1.0) + + # Pure turbo color — no gray blending for strong signals + rgb_t = turbo_rgb01(np.array([intensity]))[0] + tr, tg, tb = rgb_t[0] / 255.0, rgb_t[1] / 255.0, rgb_t[2] / 255.0 + + # Blend: very low intensity stays slightly gray, rest is full turbo + if intensity < 0.08: + r, g, b = 0.45, 0.45, 0.47 + else: + sat = min(intensity * 1.5, 1.0) + r = 0.45 * (1 - sat) + tr * sat + g = 0.45 * (1 - sat) + tg * sat + b = 0.47 * (1 - sat) + tb * sat + + # Radius: massive difference between quiet and active + radius = self.panel.base_radius * ( + self.RADIUS_MIN_FRAC + + (self.RADIUS_MAX_FRAC - self.RADIUS_MIN_FRAC) * intensity + ) + + # Opacity + opacity = 0.12 + 0.88 * intensity + + self.panel._spheres[i].GetProperty().SetColor(r, g, b) + self.panel._spheres[i].GetProperty().SetOpacity(opacity) + + mapper = self.panel._spheres[i].GetMapper() + src = vtk.vtkSphereSource() + src.SetCenter(*self.panel.centers[i]) + src.SetRadius(radius) + src.SetPhiResolution(16) + src.SetThetaResolution(16) + src.Update() + mapper.SetInputData(src.GetOutput()) + mapper.Update() + + self._showing_end = show_end + + def tick(self): + """Toggle between start and end states on a timer.""" + if not self.active: + return + + self._tick_counter += 1 + if self._tick_counter >= self.TOGGLE_TICKS: + self._tick_counter = 0 + self._apply_state(not self._showing_end) + + def stop(self): + self.active = False + self._start_vals = None + self._end_vals = None + self.panel.restore_spheres() + self.panel.reset_colors() + + def is_active(self): + return self.active + + +# --------------------------------------------------------------------------- +# ROI Path Comparison — animate difference between two paths +# --------------------------------------------------------------------------- + +class ROIPathCompare: + """Animate the difference between two saved paths. + + Shows how Path A vs Path B differ in their ROI activation trajectories. + Color encodes which path activates each ROI more: + - Cyan = Path A dominant + - Magenta = Path B dominant + - Gray = similar + Sphere size pulses with magnitude of the difference. + """ + + N_TOP = 100 + RADIUS_MIN_FRAC = 0.6 + RADIUS_MAX_FRAC = 2.5 + + def __init__(self, roi_panel: ROIPanel, knn: 'ManifoldToROIKNN'): + self.panel = roi_panel + self.knn = knn + self.active = False + self._diff_frames: np.ndarray | None = None # (n, n_rois) + self._top_mask: np.ndarray | None = None + self._global_max: float = 1.0 + self._n_frames = 0 + self._cursor = 0.0 + self._direction = 1 + self._tick_accum = 0.0 + + def build(self, path_a: list[np.ndarray], path_b: list[np.ndarray], + n_samples: int = 60): + """Build comparison animation from two paths.""" + if len(path_a) < 2 or len(path_b) < 2: + print("[compare] Both paths need at least 2 points.") + return + + pts_a = ROIPathAnimation._resample_path( + np.array(path_a, np.float32), n_samples) + pts_b = ROIPathAnimation._resample_path( + np.array(path_b, np.float32), n_samples) + + frames_a = np.zeros((n_samples, self.panel.n_rois), np.float32) + frames_b = np.zeros((n_samples, self.panel.n_rois), np.float32) + for i in range(n_samples): + frames_a[i] = self.knn.query(pts_a[i]) + frames_b[i] = self.knn.query(pts_b[i]) + + # Difference: positive = A stronger, negative = B stronger + diff = frames_a - frames_b + + # Top N most different ROIs + max_diff = np.max(np.abs(diff), axis=0) + n_top = min(self.N_TOP, self.panel.n_rois) + top_idx = np.argsort(-max_diff)[:n_top] + mask = np.zeros(self.panel.n_rois, bool) + mask[top_idx] = True + + self._diff_frames = diff + self._top_mask = mask + self._global_max = float(np.percentile(max_diff[mask], 97)) + 1e-8 + self._n_frames = n_samples + self._cursor = 0.0 + self._direction = 1 + self._tick_accum = 0.0 + self.active = True + + # Dim non-selected + for i in range(self.panel.n_rois): + if not mask[i]: + self.panel._spheres[i].GetProperty().SetColor(0.3, 0.3, 0.3) + self.panel._spheres[i].GetProperty().SetOpacity(0.06) + mapper = self.panel._spheres[i].GetMapper() + src = vtk.vtkSphereSource() + src.SetCenter(*self.panel.centers[i]) + src.SetRadius(self.panel.base_radius * 0.35) + src.SetPhiResolution(8) + src.SetThetaResolution(8) + src.Update() + mapper.SetInputData(src.GetOutput()) + + print(f"[compare] Built comparison: {n_samples} frames, " + f"{n_top} active ROIs") + + def tick(self): + if not self.active or self._diff_frames is None: + return + + self._tick_accum += 0.5 + if self._tick_accum < 1.0: + return + self._tick_accum -= 1.0 + + self._cursor += self._direction + if self._cursor >= self._n_frames - 1: + self._cursor = float(self._n_frames - 1) + self._direction = -1 + elif self._cursor <= 0: + self._cursor = 0.0 + self._direction = 1 + + idx_lo = int(self._cursor) + idx_hi = min(idx_lo + 1, self._n_frames - 1) + frac = self._cursor - idx_lo + cur = (self._diff_frames[idx_lo] * (1 - frac) + + self._diff_frames[idx_hi] * frac) + + gm = self._global_max + + for i in range(self.panel.n_rois): + if not self._top_mask[i]: + continue + d = float(cur[i]) + mag = min(abs(d) / gm, 1.0) + + # Cyan (A dominant) ↔ gray ↔ Magenta (B dominant) + if d > 0: + # Path A stronger → cyan + r = 0.5 * (1 - mag) + g = 0.5 + 0.5 * mag + b = 0.5 + 0.5 * mag + else: + # Path B stronger → magenta + r = 0.5 + 0.5 * mag + g = 0.5 * (1 - mag) + b = 0.5 + 0.5 * mag + + radius = self.panel.base_radius * ( + self.RADIUS_MIN_FRAC + + (self.RADIUS_MAX_FRAC - self.RADIUS_MIN_FRAC) * mag) + opacity = 0.2 + 0.8 * mag + + self.panel._spheres[i].GetProperty().SetColor(r, g, b) + self.panel._spheres[i].GetProperty().SetOpacity(opacity) + + mapper = self.panel._spheres[i].GetMapper() + src = vtk.vtkSphereSource() + src.SetCenter(*self.panel.centers[i]) + src.SetRadius(radius) + src.SetPhiResolution(16) + src.SetThetaResolution(16) + src.Update() + mapper.SetInputData(src.GetOutput()) + mapper.Update() + + def stop(self): + self.active = False + self._diff_frames = None + self.panel.restore_spheres() + self.panel.reset_colors() + + def is_active(self): + return self.active + + +# --------------------------------------------------------------------------- +# Main application +# --------------------------------------------------------------------------- + +def main(): + ap = argparse.ArgumentParser(description="ROI Flow Mode — manifold + ROI visualization") + + ap.add_argument("--meta", type=Path, default=DEFAULT_META) + ap.add_argument("--oos", type=Path, default=DEFAULT_OOS) + ap.add_argument("--probe-embed", type=Path, default=DEFAULT_PROBE_EMBED) + ap.add_argument("--probe-roi", type=Path, default=DEFAULT_PROBE_ROI) + ap.add_argument("--probe-roi-centers", type=Path, default=DEFAULT_PROBE_ROI_CENTERS) + ap.add_argument("--roi-names", type=Path, default=None, + help="JSON list of R ROI names (auto-mapped to brain regions if not given)") + ap.add_argument("--probe-k", type=int, default=256) + ap.add_argument("--probe-sigma", type=float, default=0.0) + ap.add_argument("--dt", type=float, default=1.0) + ap.add_argument("--speed-scale", type=float, default=1.0) + ap.add_argument("--max-step-frac", type=float, default=0.01) + ap.add_argument("--fps", type=int, default=60) + ap.add_argument("--stride", type=int, default=3) + ap.add_argument("--respawn-jitter", type=float, default=0.015) + ap.add_argument("--window-size", type=int, nargs=2, default=[1600, 800]) + ap.add_argument("--hq", action="store_true", help="Use gpt-5.4 instead of gpt-5.4-mini") + ap.add_argument("--debug", action="store_true") + + args = ap.parse_args() + model = "gpt-5.4" if args.hq else "gpt-5.4-mini" + print(f"[config] ROI Flow Mode | LLM model: {model}") + + # Check data exists + for name, path in [("probe-embed", args.probe_embed), + ("probe-roi", args.probe_roi), + ("probe-roi-centers", args.probe_roi_centers)]: + if not path.exists(): + print(f"\n[error] Missing: {path}") + print(f" Run: python scripts/download_roi_flow_data.py") + sys.exit(1) + + # ---------- Load MDN field ---------- + print(f"[field] loading {args.meta} ...") + if not args.meta.exists(): + print(f"\n[error] Missing: {args.meta}") + print(f" Run: python scripts/download_roi_flow_data.py") + sys.exit(1) + fld = load_field(args.meta) + G = fld["G"] + amin, amax = fld["amin"], fld["amax"] + diag = float(np.linalg.norm(amax - amin)) + sampler = TriLinearSampler(fld["mean"], amin, amax) + V_all = fld["mean"].reshape(-1, 3) + vmax_mean = float(np.percentile(np.linalg.norm(V_all, axis=1), 99.5)) + target_step = args.max_step_frac * max(diag, 1e-9) + print(f"[field] grid={G}, diag={diag:.4f}, vmax={vmax_mean:.6f}") + + # ---------- Load OOS points ---------- + oos_pts = _load_points_any(args.oos) + print(f"[oos] {oos_pts.shape[0]} points") + + # Seed particles from OOS∩TRAIN overlap (like original script) + from scipy.spatial import cKDTree + train_pts = fld.get("TRAIN") + if train_pts is not None and len(train_pts) > 0: + overlap_radius = 0.01 * max(diag, 1e-9) + tree = cKDTree(train_pts.astype(np.float32)) + dists, _ = tree.query(oos_pts.astype(np.float32), k=1) + cand = oos_pts[dists <= overlap_radius] + if len(cand) > 100: + seed_pts = cand.astype(np.float32) + print(f"[seed] {len(seed_pts)} OOS∩TRAIN overlap points") + else: + in_bounds = np.all((oos_pts >= amin) & (oos_pts <= amax), axis=1) + seed_pts = oos_pts[in_bounds] if in_bounds.sum() > 100 else oos_pts + print(f"[seed] {len(seed_pts)} OOS points (overlap too sparse)") + else: + in_bounds = np.all((oos_pts >= amin) & (oos_pts <= amax), axis=1) + seed_pts = oos_pts[in_bounds] if in_bounds.sum() > 100 else oos_pts + print(f"[seed] {len(seed_pts)} OOS points (no TRAIN)") + + # ---------- Load probe data ---------- + X_embed = np.load(str(args.probe_embed)).astype(np.float32) + Y_roi = np.load(str(args.probe_roi)).astype(np.float32) + C_centers = np.load(str(args.probe_roi_centers)).astype(np.float32) + print(f"[probe] embed: {X_embed.shape}, roi: {Y_roi.shape}, centers: {C_centers.shape}") + assert X_embed.shape[0] == Y_roi.shape[0] + assert C_centers.shape[0] == Y_roi.shape[1] + n_rois = Y_roi.shape[1] + + # ROI names + if args.roi_names and args.roi_names.exists(): + roi_names = json.loads(args.roi_names.read_text(encoding="utf-8")) + else: + print("[roi-map] Mapping ROI centroids to brain regions...") + roi_names = _map_roi_to_brain_regions(C_centers) + assert len(roi_names) == n_rois + + knn = ManifoldToROIKNN(X_embed, Y_roi, k=args.probe_k, sigma=args.probe_sigma) + analyzer = ROIFlowAnalyzer(roi_names, C_centers) + llm = ROIFlowLLM(model=model, debug=args.debug) + _sigma_str = "auto" if knn.sigma == 0 else f"{knn.sigma:.4f}" + print(f"[knn] built with k={knn.k}, sigma={_sigma_str}") + + # ---------- VTK setup ---------- + win = vtk.vtkRenderWindow() + win.SetSize(*args.window_size) + win.SetWindowName("mindVisualizer — ROI Flow Mode") + + ren_manifold = vtk.vtkRenderer() + ren_manifold.SetViewport(0.0, 0.0, 0.55, 1.0) + ren_manifold.SetBackground(0.0, 0.0, 0.0) + win.AddRenderer(ren_manifold) + + ren_roi = vtk.vtkRenderer() + ren_roi.SetViewport(0.55, 0.0, 1.0, 1.0) + ren_roi.SetBackground(0.02, 0.02, 0.04) + win.AddRenderer(ren_roi) + + iren = vtk.vtkRenderWindowInteractor() + iren.SetRenderWindow(win) + style = vtkInteractorStyleTrackballCamera() + iren.SetInteractorStyle(style) + + # ---------- OOS overlay (reuse build_cloud from src/main.py) ---------- + n_oos = len(oos_pts) + oos_rgba = np.zeros((n_oos, 4), np.uint8) + oos_rgba[:, 0] = 100; oos_rgba[:, 1] = 100; oos_rgba[:, 2] = 120; oos_rgba[:, 3] = 72 + _, oos_colors, oos_pd, oos_actor = build_cloud(oos_pts, point_size=1.5, rgba=oos_rgba) + ren_manifold.AddActor(oos_actor) + + # ---------- MDN Particles ---------- + rng = np.random.default_rng(0) + n_particles = min(len(seed_pts), 12000) + sel = rng.choice(len(seed_pts), size=n_particles, + replace=len(seed_pts) < n_particles) + P = seed_pts[sel].copy() + overlap_sigma = args.respawn_jitter * max(diag, 1e-9) + P += rng.standard_normal(P.shape).astype(np.float32) * overlap_sigma + np.clip(P, amin, amax, out=P) + + ttl_lo, ttl_hi = 30, 120 + ttl = rng.integers(ttl_lo, ttl_hi + 1, size=n_particles, dtype=np.int32) + ages = rng.integers(0, ttl_hi, size=n_particles, dtype=np.int32) + + # Initial color: white with low alpha (like original) + init_rgba = np.tile(np.array([[255, 255, 255, 48]], np.uint8), (n_particles, 1)) + pts_vtk, colors_arr, p_pd, p_actor = build_cloud(P, point_size=2.0, rgba=init_rgba) + ren_manifold.AddActor(p_actor) + + dt = [args.dt] + speed_scale = [args.speed_scale] + + # ---------- ROI Panel ---------- + roi_panel = ROIPanel(ren_roi, C_centers, roi_names) + + # ---------- ROI Flow Dots (particle mode — alternative) ---------- + roi_flow_dots = ROIFlowDots(ren_roi, C_centers) + + # ---------- ROI Path Animation (default mode) ---------- + roi_path_anim = ROIPathAnimation(roi_panel, knn) + roi_anim_mode = ["path"] # "path" (default) or "particles" + + # ---------- ROI Path Comparison ---------- + roi_path_compare = ROIPathCompare(roi_panel, knn) + saved_paths = {"A": None, "B": None} # saved path point lists + saved_trail_actors = {"A": None, "B": None} # trail actors for saved paths + + # ---------- Probe state ---------- + probe_mode = [None] # None, "flow", "manual" + probe_pos = [None] + probe_path = [] + probe_start_roi = [None] + probe_frozen = [False] + trail_actor_ref = [None] # reference to current spline trail actor + placement_mode = [None] # None, "flow", "manual" — set by G/M key, consumed by click + + # Probe marker — proportional to manifold size + probe_radius = diag * 0.008 + probe_sphere = vtk.vtkSphereSource() + probe_sphere.SetRadius(probe_radius) + probe_sphere.SetPhiResolution(16) + probe_sphere.SetThetaResolution(16) + probe_mapper = vtk.vtkPolyDataMapper() + probe_mapper.SetInputConnection(probe_sphere.GetOutputPort()) + probe_actor = vtk.vtkActor() + probe_actor.SetMapper(probe_mapper) + probe_actor.GetProperty().SetColor(1.0, 1.0, 0.0) + probe_actor.VisibilityOff() + ren_manifold.AddActor(probe_actor) + + # ---------- Text overlays (reuse from src/main.py) ---------- + overlay = VtkTextOverlay(ren_manifold, max_lines=6) + + # Legend (top-left, white Courier) + add_window_legend(ren_manifold, [ + "G then click place probe (flow)", + "M then click manual path mode", + "Shift+G freeze & interpret", + "V toggle ROI anim mode", + "1 / 2 save path A / B", + "D compare A vs B", + "C clear all", + "+/- speed | Q quit", + ], font_px=12) + + gpt_pending = {"result": None} + + # ROI mode legend (top-left of ROI viewport) + roi_legend_actor = vtk.vtkTextActor() + roi_legend_actor.SetInput("") + roi_leg_tp = roi_legend_actor.GetTextProperty() + roi_leg_tp.SetFontFamilyToCourier() + roi_leg_tp.SetFontSize(11) + roi_leg_tp.SetColor(0.8, 0.8, 0.8) + roi_leg_tp.SetOpacity(0.7) + roi_leg_tp.SetJustificationToLeft() + roi_leg_tp.SetVerticalJustificationToTop() + roi_legend_actor.GetPositionCoordinate().SetCoordinateSystemToNormalizedViewport() + roi_legend_actor.GetPositionCoordinate().SetValue(0.02, 0.97) + ren_roi.AddActor(roi_legend_actor) + + # Static legend for ROI viewport (bottom-left) + add_window_legend(ren_roi, [ + "V switch view mode", + ], font_px=11) + + def _update_roi_legend(): + if roi_flow_dots.is_active(): + roi_legend_actor.SetInput( + "PARTICLE FLOW\n" + "Particles: donor -> receiver\n" + "Burst mode | V to switch") + elif roi_path_anim.is_active(): + roi_legend_actor.SetInput( + "PATH ANIMATION\n" + "Flipping START <-> END\n" + "V to switch view") + elif roi_path_compare.is_active(): + roi_legend_actor.SetInput( + "PATH COMPARISON\n" + "Cyan=A Magenta=B") + else: + roi_legend_actor.SetInput("") + + # LLM result text panel in the ROI viewport (bottom, green text) + gpt_text_actor = vtk.vtkTextActor() + gpt_text_actor.SetInput("") + gpt_tp = gpt_text_actor.GetTextProperty() + gpt_tp.SetFontFamilyToCourier() + gpt_tp.SetFontSize(11) + gpt_tp.SetColor(0.3, 0.9, 0.3) # green + gpt_tp.SetOpacity(0.9) + gpt_tp.SetJustificationToLeft() + gpt_tp.SetVerticalJustificationToBottom() + gpt_text_actor.GetPositionCoordinate().SetCoordinateSystemToNormalizedViewport() + gpt_text_actor.GetPositionCoordinate().SetValue(0.02, 0.02) + gpt_text_actor.VisibilityOff() + ren_roi.AddActor(gpt_text_actor) + + def _word_wrap(text: str, width: int = 50, max_lines: int = 18) -> str: + """Word-wrap text to fit the ROI viewport.""" + import textwrap + lines = [] + for paragraph in text.split('\n'): + wrapped = textwrap.wrap(paragraph, width=width) or [''] + lines.extend(wrapped) + if len(lines) > max_lines: + lines = lines[:max_lines - 1] + ['...'] + return '\n'.join(lines) + + # ---------- Helper: remove trail ---------- + def _remove_trail(): + if trail_actor_ref[0] is not None: + ren_manifold.RemoveActor(trail_actor_ref[0]) + trail_actor_ref[0] = None + + # ---------- Helper: rebuild trail from path ---------- + def _rebuild_trail(): + _remove_trail() + if len(probe_path) >= 2: + pts_arr = np.array(probe_path, dtype=np.float32) + actor = _build_spline_trail(pts_arr, diag) + if actor is not None: + ren_manifold.AddActor(actor) + trail_actor_ref[0] = actor + + # ---------- Helper: place probe ---------- + def _place_probe(pos, mode): + pos = np.clip(pos, amin, amax) + probe_mode[0] = mode + probe_frozen[0] = False + probe_pos[0] = pos.copy() + probe_path.clear() + probe_path.append(pos.copy()) + probe_start_roi[0] = knn.query(pos) + + roi_panel.update_values(probe_start_roi[0]) + roi_flow_dots.stop() + roi_path_anim.stop() + roi_path_compare.stop() + _remove_trail() + + probe_sphere.SetCenter(*pos) + probe_actor.VisibilityOn() + + overlay.clear_log() + overlay.hide_gpt() + gpt_pending["result"] = None + + mode_label = "following flow" if mode == "flow" else "manual (click to extend)" + overlay.add_log(f"Probe placed [{mode_label}]") + print(f"[probe] placed at ({pos[0]:.4f}, {pos[1]:.4f}, {pos[2]:.4f}) [{mode}]") + + # ---------- Helper: freeze and analyze ---------- + def _freeze_and_analyze(): + if probe_mode[0] is None or probe_frozen[0] or probe_pos[0] is None: + return + probe_frozen[0] = True + + end_roi = knn.query(probe_pos[0]) + delta = analyzer.compute_delta(probe_start_roi[0], end_roi) + + overlay.add_log("Probe frozen — asking LLM...") + print("[probe] frozen, computing ROI delta...") + + context = analyzer.build_llm_context(delta) + print(f"\n{context}\n") + + # Start ROI animation (path mode or particle mode) + if roi_anim_mode[0] == "path": + roi_flow_dots.stop() + roi_path_anim.build_from_path(probe_path, n_samples=60) + else: + roi_path_anim.stop() + roi_panel.update_values(delta) + roi_flow_dots.start_from_delta(delta) + roi_panel.dim_spheres(0.15) + _update_roi_legend() + + # Rebuild trail as smooth spline now that path is complete + _rebuild_trail() + + def _gpt_worker(): + gpt_pending["result"] = llm.interpret_roi_flow(context) + + t = threading.Thread(target=_gpt_worker, daemon=True) + t.start() + + # ---------- Helper: reset probe (keep saved paths) ---------- + def _reset_probe(): + probe_mode[0] = None + probe_frozen[0] = False + probe_pos[0] = None + probe_path.clear() + probe_start_roi[0] = None + probe_actor.VisibilityOff() + _remove_trail() + roi_panel.reset_colors() + roi_panel.restore_spheres() + roi_flow_dots.stop() + roi_path_anim.stop() + roi_path_compare.stop() + roi_legend_actor.SetInput("") + gpt_text_actor.SetInput("") + gpt_text_actor.VisibilityOff() + overlay.clear_log() + overlay.hide_gpt() + gpt_pending["result"] = None + + # ---------- Helper: clear ---------- + def _clear_all(): + probe_mode[0] = None + probe_frozen[0] = False + probe_pos[0] = None + probe_path.clear() + probe_start_roi[0] = None + probe_actor.VisibilityOff() + _remove_trail() + roi_panel.reset_colors() + roi_panel.restore_spheres() + roi_flow_dots.stop() + roi_path_anim.stop() + roi_path_compare.stop() + # Remove saved path trails + for k in ("A", "B"): + if saved_trail_actors[k] is not None: + ren_manifold.RemoveActor(saved_trail_actors[k]) + saved_trail_actors[k] = None + overlay.clear_log() + overlay.hide_gpt() + gpt_text_actor.SetInput("") + gpt_text_actor.VisibilityOff() + roi_legend_actor.SetInput("") + gpt_pending["result"] = None + + # ---------- Callbacks ---------- + trail_rebuild_counter = [0] + + def on_key(obj, event): + key = iren.GetKeySym() + shift = bool(iren.GetShiftKey()) + + if key == "g" and not shift: + # Enter flow placement mode — next click places probe + placement_mode[0] = "flow" + overlay.add_log("Click to place probe (flow mode)") + + elif key == "m" and not shift: + if probe_mode[0] == "manual" and not probe_frozen[0] and probe_pos[0] is not None: + # Already in manual mode with active probe — extend path + placement_mode[0] = "manual_extend" + overlay.add_log("Click to add path point") + else: + # Start new manual path + placement_mode[0] = "manual" + overlay.add_log("Click to place probe (manual mode)") + + elif key == "G" or (key == "g" and shift): + _freeze_and_analyze() + + elif key in ("c", "C"): + placement_mode[0] = None + _clear_all() + + elif key in ("plus", "equal"): + speed_scale[0] *= 1.25 + overlay.add_log(f"Speed: {speed_scale[0]:.2f}") + elif key == "minus": + speed_scale[0] /= 1.25 + overlay.add_log(f"Speed: {speed_scale[0]:.2f}") + + elif key == "v" and not shift: + # Toggle between path animation and particle flow mode + if roi_anim_mode[0] == "path": + roi_anim_mode[0] = "particles" + overlay.add_log("ROI mode: particles") + else: + roi_anim_mode[0] = "path" + overlay.add_log("ROI mode: path animation") + # If frozen, restart with new mode + if probe_frozen[0] and probe_pos[0] is not None: + end_roi = knn.query(probe_pos[0]) + delta = analyzer.compute_delta(probe_start_roi[0], end_roi) + if roi_anim_mode[0] == "path": + roi_flow_dots.stop() + roi_panel.restore_spheres() + roi_path_anim.build_from_path(probe_path, n_samples=60) + else: + roi_path_anim.stop() + roi_panel.update_values(delta) + roi_flow_dots.start_from_delta(delta) + roi_panel.dim_spheres(0.15) + _update_roi_legend() + + elif key == "1" and not shift: + # Save current path as Path A, then reset probe for next path + if len(probe_path) >= 2: + saved_paths["A"] = [p.copy() for p in probe_path] + overlay.add_log(f"Path A saved ({len(probe_path)} pts)") + print(f"[compare] Path A saved: {len(probe_path)} points") + if saved_trail_actors["A"] is not None: + ren_manifold.RemoveActor(saved_trail_actors["A"]) + ta = _build_spline_trail(np.array(probe_path, np.float32), diag) + if ta is not None: + ta.GetProperty().SetColor(0.0, 0.9, 0.9) # cyan + ta.GetProperty().SetOpacity(0.6) + ren_manifold.AddActor(ta) + saved_trail_actors["A"] = ta + # Reset probe so next path starts fresh + _reset_probe() + else: + overlay.add_log("Need a path first (place+freeze probe)") + + elif key == "2" and not shift: + # Save current path as Path B, then reset probe for next path + if len(probe_path) >= 2: + saved_paths["B"] = [p.copy() for p in probe_path] + overlay.add_log(f"Path B saved ({len(probe_path)} pts)") + print(f"[compare] Path B saved: {len(probe_path)} points") + if saved_trail_actors["B"] is not None: + ren_manifold.RemoveActor(saved_trail_actors["B"]) + tb = _build_spline_trail(np.array(probe_path, np.float32), diag) + if tb is not None: + tb.GetProperty().SetColor(0.9, 0.0, 0.9) # magenta + tb.GetProperty().SetOpacity(0.6) + ren_manifold.AddActor(tb) + saved_trail_actors["B"] = tb + _reset_probe() + else: + overlay.add_log("Need a path first (place+freeze probe)") + + elif key == "d" and not shift: + # Compare Path A vs Path B with LLM analysis + if saved_paths["A"] is None or saved_paths["B"] is None: + overlay.add_log("Save Path A (1) and Path B (2) first") + else: + roi_flow_dots.stop() + roi_path_anim.stop() + roi_panel.restore_spheres() + roi_path_compare.build(saved_paths["A"], saved_paths["B"]) + overlay.add_log("Comparing Path A (cyan) vs B (magenta)...") + print("[compare] Started path comparison animation") + + # Compute deltas and LLM context for both paths + pa, pb = saved_paths["A"], saved_paths["B"] + roi_start_a = knn.query(np.array(pa[0], np.float32)) + roi_end_a = knn.query(np.array(pa[-1], np.float32)) + delta_a = analyzer.compute_delta(roi_start_a, roi_end_a) + ctx_a = analyzer.build_llm_context(delta_a) + + roi_start_b = knn.query(np.array(pb[0], np.float32)) + roi_end_b = knn.query(np.array(pb[-1], np.float32)) + delta_b = analyzer.compute_delta(roi_start_b, roi_end_b) + ctx_b = analyzer.build_llm_context(delta_b) + + print(f"\n--- PATH A ---\n{ctx_a}\n--- PATH B ---\n{ctx_b}\n") + + # LLM comparison in background + def _compare_worker(): + gpt_pending["result"] = llm.compare_two_paths(ctx_a, ctx_b) + + t = threading.Thread(target=_compare_worker, daemon=True) + t.start() + overlay.add_log("Asking LLM to compare paths...") + _update_roi_legend() + + elif key in ("q", "Escape"): + iren.TerminateApp() + + iren.AddObserver("KeyPressEvent", on_key) + + def on_click(obj, event): + if placement_mode[0] is None: + return # No placement pending — normal click behavior + + x, y = iren.GetEventPosition() + picker = vtk.vtkWorldPointPicker() + picker.Pick(x, y, 0, ren_manifold) + pos = np.array(picker.GetPickPosition(), dtype=np.float32) + pos = np.clip(pos, amin, amax) + + mode = placement_mode[0] + placement_mode[0] = None # Consume the placement + + if mode == "flow": + _place_probe(pos, "flow") + elif mode == "manual": + _place_probe(pos, "manual") + elif mode == "manual_extend": + probe_pos[0] = pos.copy() + probe_path.append(pos.copy()) + probe_sphere.SetCenter(*pos) + _rebuild_trail() + roi_vec = knn.query(pos) + roi_panel.update_values(roi_vec) + overlay.add_log(f"Path point {len(probe_path)}") + + iren.AddObserver("LeftButtonPressEvent", on_click) + + # ---------- Timer loop ---------- + def on_timer(obj, event): + nonlocal P, ages, ttl + + # Check for GPT result + if gpt_pending["result"] is not None: + text = gpt_pending["result"] + gpt_pending["result"] = None + overlay.show_gpt(text) + overlay.add_log("LLM interpretation ready.") + # Show in ROI viewport as well + gpt_text_actor.SetInput(_word_wrap(text, width=45, max_lines=16)) + gpt_text_actor.VisibilityOn() + print(f"\n--- ROI FLOW INTERPRETATION ---\n{text}\n--- END ---\n") + + # Advect particles + V = sampler.sample_vec(P) + step = dt[0] * speed_scale[0] * (target_step / max(vmax_mean, 1e-9)) + P[:] += V * step + np.clip(P, amin, amax, out=P) + + # Death/respawn + ages += 1 + dead = ages >= ttl + if np.any(dead): + n_dead = dead.sum() + sel2 = rng.integers(0, len(seed_pts), size=n_dead) + base = seed_pts[sel2] + J = rng.standard_normal((n_dead, 3)).astype(np.float32) * overlap_sigma + P[dead] = np.clip(base + J, amin, amax) + ages[dead] = 0 + ttl[dead] = rng.integers(ttl_lo, ttl_hi + 1, size=n_dead, dtype=np.int32) + + # Update colors (vectorized turbo colormap) + speeds = np.linalg.norm(V, axis=1) + s_max = float(np.percentile(speeds, 97)) + 1e-8 + t_vals = np.clip(speeds / s_max, 0.0, 1.0) + rgb = turbo_rgb01(t_vals) + alpha = np.full(n_particles, 200, np.uint8) + rgba = np.concatenate([rgb, alpha[:, None]], axis=1) + + pts_vtk.SetData(numpy_to_vtk(P, deep=True)) + colors_arr.DeepCopy(numpy_to_vtk(rgba, deep=True)) + colors_arr.Modified() + p_pd.Modified() + + # Advect probe (flow mode) + if probe_mode[0] == "flow" and not probe_frozen[0] and probe_pos[0] is not None: + pos = probe_pos[0] + v = sampler.sample_vec(pos.reshape(1, 3))[0] + pos += v * step + np.clip(pos, amin, amax, out=pos) + probe_pos[0] = pos + probe_sphere.SetCenter(*pos) + probe_path.append(pos.copy()) + + # Rebuild trail periodically (every 20 steps) for smooth spline + trail_rebuild_counter[0] += 1 + if trail_rebuild_counter[0] >= 20: + trail_rebuild_counter[0] = 0 + _rebuild_trail() + + if len(probe_path) % 5 == 0: + roi_vec = knn.query(pos) + roi_panel.update_values(roi_vec) + + # Tick ROI animation (path, particles, or compare — whichever is active) + if roi_path_anim.is_active(): + roi_path_anim.tick() + if roi_flow_dots.is_active(): + roi_flow_dots.tick() + if roi_path_compare.is_active(): + roi_path_compare.tick() + + win.Render() + + iren.Initialize() + iren.CreateRepeatingTimer(int(1000 / args.fps)) + iren.AddObserver("TimerEvent", on_timer) + + ren_manifold.ResetCamera() + ren_roi.ResetCamera() + + print("\n=== ROI Flow Mode ===") + print(" G then click — place probe (follows flow)") + print(" M then click — manual path mode (click to extend)") + print(" Shift+G — freeze & interpret ROI flow") + print(" V — toggle ROI anim: path ↔ particles") + print(" 1 / 2 — save current path as A / B") + print(" D — compare Path A vs Path B") + print(" C — clear all") + print(" +/- — speed scale") + print(" Q — quit") + print("=" * 40) + + win.Render() + iren.Start() + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000000000000000000000000000000000000..5a9b0d5a8241e387bf53add3b5d1dd8592d6d5fa --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,37 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "mindvisualizer" +version = "0.1.0" +description = "Brain flow particle visualizer with probe analysis" +readme = "README.md" +requires-python = ">=3.10,<3.13" +dependencies = [ + "numpy>=1.24", + "scipy>=1.10", + "vtk>=9.2", + "langchain>=0.3", + "langchain-openai>=0.3", + "langchain-community>=0.3", + "faiss-cpu>=1.7", + "python-dotenv>=1.0", + "certifi>=2023.0", + "tiktoken>=0.5", + "brainglobe-atlasapi", + "nibabel", + "numpy", + "nilearn", + "scikit-image" +] + +[project.scripts] +mindviz = "mindvisualizer.main:main" + +[tool.setuptools] +package-dir = {"" = "src"} + +[tool.setuptools.packages.find] +where = ["src"] +include = ["mindvisualizer*"] \ No newline at end of file diff --git a/scripts/download_roi_flow_data.py b/scripts/download_roi_flow_data.py new file mode 100644 index 0000000000000000000000000000000000000000..25f70693f42ffb1aaaf29e5dc4057fe7c9a9b780 --- /dev/null +++ b/scripts/download_roi_flow_data.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python +"""Download ROI flow data from HuggingFace. + +Downloads the probe embedding, ROI vectors, ROI centers, and OOS manifold +points needed for ROI flow mode. + +Usage: + python scripts/download_roi_flow_data.py +""" + +import os +import sys +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +DATA_DIR = PROJECT_ROOT / "data" / "roi_flow" + + +def _fix_ssl(): + try: + import certifi + os.environ["SSL_CERT_FILE"] = certifi.where() + os.environ["REQUESTS_CA_BUNDLE"] = certifi.where() + except ImportError: + pass + + +def main(): + _fix_ssl() + + try: + from huggingface_hub import hf_hub_download + except ImportError: + print("huggingface_hub not installed. Install with:") + print(" pip install huggingface_hub") + sys.exit(1) + + REPO_ID = "Pixedar/mindVisualizer-roi-flow-data" + FILES = [ + # Probe data + "probe_embed.npy", + "probe_roi.npy", + "probe_roi_centers.npy", + # Manifold OOS points + "universal_soul_2sdm_rest_points.ply", + # MDN flow field (manifold space, not brain space) + "mdn_training_all_points.npy", + "mdn_universal_raw_grid64_meta.json", + "mdn_universal_all_grid64_mean_xyz3.bin", + "mdn_universal_all_grid64_mu1_xyz3.bin", + "mdn_universal_all_grid64_mu2_xyz3.bin", + "mdn_universal_all_grid64_pi.bin", + "mdn_universal_all_grid64_entropy.bin", + ] + + DATA_DIR.mkdir(parents=True, exist_ok=True) + + for fname in FILES: + dest = DATA_DIR / fname + if dest.exists(): + print(f" [skip] {fname} (already exists)") + continue + + print(f" Downloading {fname}...") + try: + downloaded = hf_hub_download( + repo_id=REPO_ID, + filename=fname, + repo_type="dataset", + local_dir=str(DATA_DIR), + ) + print(f" [ok] {fname}") + except Exception as e: + print(f" [error] {fname}: {e}") + + print(f"\nDone! Files saved to: {DATA_DIR}") + print("\nYou can now run ROI flow mode:") + print(" python examples/roi_flow_mode.py") + + +if __name__ == "__main__": + main() diff --git a/scripts/setup_extra_parcellation.py b/scripts/setup_extra_parcellation.py new file mode 100644 index 0000000000000000000000000000000000000000..36383f9c99464ddab917e24bc40811ad8fdabdcb --- /dev/null +++ b/scripts/setup_extra_parcellation.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +"""Build a combined brain parcellation atlas for mindVisualizer. + +Combines three complementary atlases (all in MNI152 1mm space) into a single +NIfTI volume with a unified label map: + + Layer 1 — Harvard-Oxford Cortical (48 regions): + Broad cortical coverage with LLM-friendly names. + Layer 2 — Harvard-Oxford Subcortical (17 regions): + Thalamus, putamen, caudate, hippocampus, amygdala, etc. + Layer 3 — Julich-Brain cytoarchitectonic (62 regions, HIGHEST PRIORITY): + Fine-grained motor (BA4a/4p), somatosensory (BA1-3), visual (V1-V5), + auditory (TE1.0-1.2), Broca's (BA44/45), hippocampal subfields, + amygdala subdivisions, white matter tracts. + +Where Julich-Brain has a label, it overrides the coarser Harvard-Oxford label. +This gives maximum spatial coverage with maximum detail where available. + +Output: + data/extra_parcellation/combined_atlas.nii.gz (NIfTI volume, ~700KB) + data/extra_parcellation/combined_atlas_labels.json (label ID → name map) + +Requirements: + pip install nilearn nibabel + +Usage: + python scripts/setup_extra_parcellation.py +""" + +import json +import os +import sys +from pathlib import Path + +# Ensure project root is importable +ROOT = Path(__file__).resolve().parent.parent +OUT_DIR = ROOT / "data" / "extra_parcellation" + + +def main(): + try: + import nibabel as nib + import numpy as np + except ImportError: + print("ERROR: nibabel and numpy are required.") + print(" pip install nibabel numpy") + sys.exit(1) + + try: + import nilearn.datasets as ds + except ImportError: + print("ERROR: nilearn is required to fetch the source atlases.") + print(" pip install nilearn") + sys.exit(1) + + OUT_DIR.mkdir(parents=True, exist_ok=True) + out_nii = OUT_DIR / "combined_atlas.nii.gz" + out_labels = OUT_DIR / "combined_atlas_labels.json" + + if out_nii.exists() and out_labels.exists(): + print(f"[setup] Combined atlas already exists: {out_nii}") + print("[setup] Delete it and re-run to rebuild.") + return + + # ---- Fetch source atlases via nilearn (auto-downloads) ---- + print("[setup] Fetching Harvard-Oxford cortical atlas ...") + ho_cort = ds.fetch_atlas_harvard_oxford("cort-maxprob-thr25-1mm") + + print("[setup] Fetching Harvard-Oxford subcortical atlas ...") + ho_sub = ds.fetch_atlas_harvard_oxford("sub-maxprob-thr25-1mm") + + print("[setup] Fetching Julich-Brain cytoarchitectonic atlas ...") + juelich = ds.fetch_atlas_juelich("maxprob-thr25-1mm") + + # ---- Load NIfTI images ---- + def _load(maps): + return maps if hasattr(maps, "dataobj") else nib.load(maps) + + ho_cort_img = _load(ho_cort["maps"]) + ho_sub_img = _load(ho_sub["maps"]) + juelich_img = _load(juelich["maps"]) + + ho_cort_data = np.asarray(ho_cort_img.dataobj) + ho_sub_data = np.asarray(ho_sub_img.dataobj) + juelich_data = np.asarray(juelich_img.dataobj) + + assert ho_cort_data.shape == ho_sub_data.shape == juelich_data.shape, \ + "Atlas shapes do not match — cannot combine" + assert np.allclose(ho_cort_img.affine, juelich_img.affine), \ + "Atlas affines do not match — not in the same MNI space" + + # ---- Combine: lowest priority first, highest last ---- + combined = np.zeros(ho_cort_data.shape, dtype=np.int32) + combined_labels = {} + + # Layer 1: Harvard-Oxford Cortical (label IDs 1–48) + for i in range(1, len(ho_cort["labels"])): + combined[ho_cort_data == i] = i + combined_labels[str(i)] = str(ho_cort["labels"][i]) + + # Layer 2: Harvard-Oxford Subcortical (label IDs 100+) + for i in range(1, len(ho_sub["labels"])): + name = str(ho_sub["labels"][i]) + # Skip overly broad labels + if "Cortex" in name or "White Matter" in name: + continue + label_id = 100 + i + combined[ho_sub_data == i] = label_id + combined_labels[str(label_id)] = name + + # Layer 3: Julich-Brain (label IDs 200+, overwrites everything) + for i in range(1, len(juelich["labels"])): + label_id = 200 + i + combined[juelich_data == i] = label_id + name = str(juelich["labels"][i]) + # Clean up prefixes + if name.startswith("GM "): + name = name[3:] + elif name.startswith("WM "): + name = "WM: " + name[3:] + combined_labels[str(label_id)] = name + + # ---- Save ---- + combined_img = nib.Nifti1Image(combined, ho_cort_img.affine) + nib.save(combined_img, str(out_nii)) + + with open(out_labels, "w", encoding="utf-8") as f: + json.dump(combined_labels, f, indent=2, ensure_ascii=False) + + n_labels = len(combined_labels) + coverage = int((combined > 0).sum()) + total = int(combined.size) + pct = 100 * coverage / total + + print(f"\n[setup] Combined atlas saved:") + print(f" NIfTI: {out_nii} ({os.path.getsize(out_nii):,} bytes)") + print(f" Labels: {out_labels} ({n_labels} labels)") + print(f" Coverage: {coverage:,} / {total:,} voxels ({pct:.1f}%)") + print(f" Sources:") + n_ho_c = sum(1 for k in combined_labels if 1 <= int(k) <= 99) + n_ho_s = sum(1 for k in combined_labels if 100 <= int(k) <= 199) + n_jue = sum(1 for k in combined_labels if 200 <= int(k) <= 299) + print(f" Harvard-Oxford Cortical: {n_ho_c} labels") + print(f" Harvard-Oxford Subcortical: {n_ho_s} labels") + print(f" Julich-Brain: {n_jue} labels") + + +if __name__ == "__main__": + main() diff --git a/setup_brain_data.py b/setup_brain_data.py new file mode 100644 index 0000000000000000000000000000000000000000..652b43bce3784c1d5b0fb7801db29c5cee5fb10a --- /dev/null +++ b/setup_brain_data.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""Download Allen Human Brain Atlas meshes and pre-compute the voxel label grid. + +This script MUST be run once before using mindVisualizer. It: + 1. Downloads the Allen Human Brain Atlas (500um) via brainglobe + 2. Copies OBJ mesh files to data/meshes/ + 3. Copies structures.json (region name mappings) to data/ + 4. Pre-computes a 100^3 voxel label grid for fast point-in-region queries + and saves it to data/label_grid_cache.npz + +Usage: + pip install brainglobe-atlasapi + python setup_brain_data.py +""" + +import shutil +import json +from pathlib import Path + +import numpy as np + +PROJECT_ROOT = Path(__file__).resolve().parent +DATA_DIR = PROJECT_ROOT / "data" +MESH_DIR = DATA_DIR / "meshes" +ALIGNMENT_FILE = DATA_DIR / "brain_alignment.json" +GRID_CACHE = DATA_DIR / "label_grid_cache.npz" + + +def main(): + # ---- Step 1: Download atlas ---- + print("[setup] Downloading Allen Human Brain Atlas (500um)...") + print(" (This is a one-time download, ~269 MB)") + from brainglobe_atlasapi import BrainGlobeAtlas + atlas = BrainGlobeAtlas("allen_human_500um") + + atlas_dir = Path.home() / ".brainglobe" / "allen_human_500um_v1.0" + src_meshes = atlas_dir / "meshes" + + if not src_meshes.exists(): + print(f"[setup] ERROR: Atlas meshes not found at {src_meshes}") + return + + # ---- Step 2: Copy meshes to both meshes/ and meshes_obj/ ---- + MESH_OBJ_DIR = DATA_DIR / "meshes_obj" + MESH_DIR.mkdir(parents=True, exist_ok=True) + MESH_OBJ_DIR.mkdir(parents=True, exist_ok=True) + obj_files = list(src_meshes.glob("*.obj")) + print(f"[setup] Copying {len(obj_files)} OBJ meshes...") + + copied = 0 + for obj in obj_files: + for target_dir in (MESH_DIR, MESH_OBJ_DIR): + dst = target_dir / obj.name + if not dst.exists() or dst.stat().st_size != obj.stat().st_size: + shutil.copy2(obj, dst) + copied += 1 + print(f"[setup] Copied meshes ({len(obj_files)} files to meshes/ and meshes_obj/)") + + # ---- Step 3: Copy structures.json ---- + structs_src = atlas_dir / "structures.json" + structs_dst = DATA_DIR / "structures.json" + if structs_src.exists(): + shutil.copy2(structs_src, structs_dst) + print(f"[setup] Copied structures.json") + else: + print(f"[setup] WARNING: structures.json not found at {structs_src}") + + # ---- Step 4: Build and cache label grid ---- + print(f"\n[setup] Building voxel label grid (this takes ~30-60 seconds)...") + import vtk + from src.mesh_overlay import FlowMeshOverlay + + ren = vtk.vtkRenderer() + win = vtk.vtkRenderWindow() + win.SetOffScreenRendering(1) + win.AddRenderer(ren) + + overlay = FlowMeshOverlay(ren=ren, win=win, + mesh_dir=MESH_DIR, + alignment_file=ALIGNMENT_FILE) + + n_regions = len(overlay.get_all_region_keys()) + print(f"[setup] Loaded {n_regions} region meshes") + + overlay.build_label_grid() + overlay.save_label_grid(GRID_CACHE) + + # ---- Summary ---- + print(f"\n{'='*50}") + print(f" Setup complete!") + print(f" Meshes: {MESH_DIR}/ ({len(obj_files)} files)") + print(f" Structures: {structs_dst}") + print(f" Label grid: {GRID_CACHE}") + print(f" Regions: {n_regions}") + print(f"{'='*50}") + print(f"\n You can now run:") + print(f" python -m src.main") + print(f" python examples/rdcim_propagation.py") + + +if __name__ == "__main__": + main() diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/__main__.py b/src/__main__.py new file mode 100644 index 0000000000000000000000000000000000000000..5d6a8109e6ce6ef4587c5fb02bb9d621aa3fa09b --- /dev/null +++ b/src/__main__.py @@ -0,0 +1,3 @@ +from .main import main + +main() diff --git a/src/brain_state.py b/src/brain_state.py new file mode 100644 index 0000000000000000000000000000000000000000..5dda8899c9e87e3c69bc1a7f94996abc5bb8a63d --- /dev/null +++ b/src/brain_state.py @@ -0,0 +1,896 @@ +"""Brain region state database. + +Stores the current cognitive/functional state of each brain region. States can be: + - Auto-initialized from a global brain state description via GPT + - Manually edited per-region + - Cleared and re-initialized at any time + - Persisted to disk as JSON + +Used by the probe system to simulate information propagation: when a probe +enters a region, the state can be modified and propagated to downstream regions. +""" + +import json +import os +import sys +from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutureTimeout +from pathlib import Path +from typing import Optional + + +def _ensure_ssl(): + """Fix SSL cert path for environments where Git overrides it. + + Must run BEFORE importing openai/langchain so the httpx client + picks up the correct certificate. + """ + try: + import certifi + cert_file = certifi.where() + cur = os.environ.get("SSL_CERT_FILE", "") + if not cur or not os.path.isfile(cur): + os.environ["SSL_CERT_FILE"] = cert_file + cur2 = os.environ.get("REQUESTS_CA_BUNDLE", "") + if not cur2 or not os.path.isfile(cur2): + os.environ["REQUESTS_CA_BUNDLE"] = cert_file + except ImportError: + pass + + +# Fix SSL before any network library is imported +_ensure_ssl() + +import openai +from dotenv import load_dotenv +from langchain_openai import ChatOpenAI +from langchain_core.prompts import ChatPromptTemplate +from langchain_core.output_parsers import StrOutputParser + + +DEFAULT_STATE_FILE = Path(__file__).resolve().parent.parent / "data" / "brain_states.json" + + +DEFAULT_MODEL = "gpt-5.4-mini" +HQ_MODEL = "gpt-5.4" + + +class BrainStateDB: + """Database of brain region states, persisted to JSON.""" + + def __init__(self, path: Path | str = DEFAULT_STATE_FILE, model: str = DEFAULT_MODEL, + debug: bool = False): + self.path = Path(path) + self.model = model + self.debug = debug + self.states: dict[str, str] = {} # region_name -> state description + self.global_state: str = "" # the overall brain state description + if self.path.exists(): + self.load() + + def _debug_prompt(self, method_name: str, prompt_text: str): + """Print the full prompt if debug mode is enabled.""" + if self.debug: + print(f"\n{'='*60}") + print(f"[DEBUG PROMPT] {method_name}") + print(f"{'='*60}") + print(prompt_text) + print(f"{'='*60}\n") + sys.stdout.flush() + + # ---------- persistence ---------- + + def load(self): + """Load states from JSON file.""" + try: + data = json.loads(self.path.read_text(encoding="utf-8")) + self.states = data.get("states", {}) + self.global_state = data.get("global_state", "") + print(f"[brain-state] loaded {len(self.states)} region states") + except Exception as e: + print(f"[brain-state] could not load {self.path}: {e}") + + def save(self): + """Save states to JSON file.""" + data = { + "global_state": self.global_state, + "states": self.states, + } + self.path.parent.mkdir(parents=True, exist_ok=True) + self.path.write_text(json.dumps(data, indent=2, ensure_ascii=False), + encoding="utf-8") + print(f"[brain-state] saved {len(self.states)} region states to {self.path}") + + def clear(self): + """Clear all states.""" + self.states.clear() + self.global_state = "" + if self.path.exists(): + self.path.unlink() + print("[brain-state] cleared all states") + + # ---------- access ---------- + + def get(self, region_name: str) -> str: + return self.states.get(region_name, "") + + def set(self, region_name: str, state: str): + self.states[region_name] = state + + def has_states(self) -> bool: + return bool(self.states) + + # ---------- GPT initialization ---------- + + def initialize_from_global(self, global_state: str, region_names: list[str], + callback=None) -> str: + """Use GPT to generate states for all regions from a global brain state. + + Args: + global_state: e.g. "someone thinking about loved ones" or "" for GPT to choose + region_names: list of region names to generate states for + callback: optional fn(status_msg) for progress updates + + Returns: + Summary of initialization. + """ + _ensure_ssl() + + load_dotenv() + + + if not global_state: + global_state = "a resting state with spontaneous mind-wandering" + + self.global_state = global_state + + # Build region list (batch into groups of ~30 for efficiency) + all_states = {} + batch_size = 30 + + for i in range(0, len(region_names), batch_size): + batch = region_names[i:i + batch_size] + if callback: + callback(f"Generating states for regions {i+1}-{i+len(batch)}/{len(region_names)}...") + + region_list = "\n".join(f"- {name}" for name in batch) + + prompt = ChatPromptTemplate.from_template( + """You are a neuroscience expert. The brain is currently in this overall state: +"{global_state}" + +For each brain region below, write a SHORT (1-2 sentences) specific description of +what this region is likely doing right now given the overall brain state. + +IMPORTANT RULES: +- Be SPECIFIC about the actual cognitive content, not generic function descriptions +- Don't force every region to match the global state — some regions may be doing + their own thing (e.g., sensory processing, homeostasis) independent of the global state +- First consider what the region generally does, then derive what it's specifically + doing in this context +- Focus on CURRENT ACTIVITY, not general capabilities + +Regions: +{regions} + +Reply as JSON object mapping region name to state description. Example: +{{"Amygdala (AMY)": "Low-level monitoring for threats; no active fear processing", ...}} +Only output valid JSON, nothing else.""" + ) + + llm = ChatOpenAI(model=self.model, temperature=0.4, max_tokens=4000) + chain = prompt | llm | StrOutputParser() + + self._debug_prompt("initialize_from_global", + prompt.format(global_state=global_state, regions=region_list)) + + try: + result = chain.invoke({ + "global_state": global_state, + "regions": region_list, + }) + # Parse JSON from response + result = result.strip() + if result.startswith("```"): + result = result.split("\n", 1)[1].rsplit("```", 1)[0] + parsed = json.loads(result) + all_states.update(parsed) + # Show each region's state in real time + for rname, rstate in parsed.items(): + if callback: + callback(f" {rname}: {rstate}") + except Exception as e: + print(f"[brain-state] GPT batch error: {e}") + for name in batch: + all_states[name] = f"(initialization failed: {e})" + + self.states = all_states + self.save() + return f"Initialized {len(self.states)} region states for: {global_state}" + + def validate_perturbation(self, region_name: str, perturbation: str) -> dict: + """Check if a perturbation is appropriate for a brain region. + + Returns: + dict with keys: + - valid (bool): True if perturbation is plausible + - region_function (str): what this region does + - suggestion (str): suggested alternative if not valid + - warning (str): warning message if perturbation is questionable + """ + _ensure_ssl() + + load_dotenv() + + + current = self.states.get(region_name, "unknown state") + + prompt = ChatPromptTemplate.from_template( + """You are a neuroscience expert. Evaluate whether this perturbation makes sense +for the specified brain region. + +Region: {region} +Current state: {current} +Requested perturbation: "{perturbation}" + +Consider: +1. What does this brain region actually do? (its primary functions) +2. Is the requested perturbation something this region CAN process? +3. If not, what WOULD be an appropriate perturbation for this region? + +Reply as JSON: +{{ + "valid": true/false, + "region_function": "brief description of what this region does", + "warning": "warning if questionable (empty string if fine)", + "suggestion": "suggested alternative perturbation if invalid (empty string if valid)" +}} +Only output valid JSON.""" + ) + + llm = ChatOpenAI(model=self.model, temperature=0.2, max_tokens=300) + chain = prompt | llm | StrOutputParser() + + self._debug_prompt("validate_perturbation", + prompt.format(region=region_name, current=current, + perturbation=perturbation)) + + try: + result = chain.invoke({ + "region": region_name, + "current": current, + "perturbation": perturbation, + }).strip() + if result.startswith("```"): + result = result.split("\n", 1)[1].rsplit("```", 1)[0] + return json.loads(result) + except Exception as e: + return {"valid": True, "region_function": "unknown", + "warning": f"(validation failed: {e})", "suggestion": ""} + + def propose_perturbations(self, region_name: str) -> list[str]: + """Ask GPT to propose 4 plausible perturbations for a brain region. + + Uses the openai SDK directly (not langchain) for reliability in + background threads. + + Args: + region_name: the region to perturb + + Returns: + List of 4 perturbation description strings. + """ + import sys + _defaults = [ + "Heightened activity in this region", + "Suppressed activity in this region", + "Shift to an alternative processing mode", + "Disrupted connectivity with downstream regions", + ] + + _ensure_ssl() + + load_dotenv() + + current = self.states.get(region_name, "unknown state") + + # Use a shorter name for prompt if the full name is very long + short_name = region_name + if len(short_name) > 80 and "(" in short_name: + short_name = short_name.split("(", 1)[1].rstrip(")") + + api_key = os.environ.get("OPENAI_API_KEY", "") + if not api_key: + print("[brain-state] OPENAI_API_KEY not set, using defaults") + sys.stdout.flush() + return _defaults + + print(f"[brain-state] propose_perturbations: calling OpenAI for '{short_name}'...") + sys.stdout.flush() + + try: + client = openai.OpenAI(api_key=api_key, timeout=30.0) + + msg_content = ( + f"You are a neuroscience expert. A user wants to perturb a brain region " + f"in a resting-state simulation.\n\n" + f"Region: {short_name}\n" + f"Current state: \"{current}\"\n\n" + f"Based on the region's known functions and its current state, propose " + f"exactly 4 different plausible ways this region's state could change. " + f"Each should be specific to this region's actual function, a realistic " + f"state change, described in 1 short sentence, and diverse from each other.\n\n" + f"Reply as a JSON array of exactly 4 strings:\n" + f'["perturbation 1", "perturbation 2", "perturbation 3", "perturbation 4"]\n' + f"Only output valid JSON." + ) + + self._debug_prompt("propose_perturbations", msg_content) + + print(f"[brain-state] propose_perturbations: sending API request...") + sys.stdout.flush() + + def _call(): + return client.chat.completions.create( + model=self.model, + temperature=0.5, + max_completion_tokens=400, + messages=[{"role": "user", "content": msg_content}], + ) + with ThreadPoolExecutor(max_workers=1) as pool: + future = pool.submit(_call) + try: + response = future.result(timeout=30) + except FutureTimeout: + print("[brain-state] propose_perturbations: TIMEOUT after 30s") + sys.stdout.flush() + return _defaults + + result = response.choices[0].message.content.strip() + print(f"[brain-state] propose_perturbations: got response, parsing...") + sys.stdout.flush() + + if result.startswith("```"): + result = result.split("\n", 1)[1].rsplit("```", 1)[0] + proposals = json.loads(result) + if isinstance(proposals, list) and len(proposals) >= 4: + return proposals[:4] + return proposals if isinstance(proposals, list) else _defaults + except Exception as e: + print(f"[brain-state] proposal error: {type(e).__name__}: {e}") + sys.stdout.flush() + import traceback + traceback.print_exc() + sys.stdout.flush() + return _defaults + + def alter_region_state(self, region_name: str, modification: str, + callback=None, skip_validation=False) -> str: + """Use GPT to alter a specific region's state. + + Args: + region_name: the region to modify + modification: description of how to change it + callback: optional fn(status_msg) for progress + skip_validation: skip perturbation validation + + Returns: + The new state description. + """ + _ensure_ssl() + + load_dotenv() + + + # Validate perturbation first + if not skip_validation: + if callback: + callback(f"Validating perturbation for {region_name}...") + validation = self.validate_perturbation(region_name, modification) + + if callback: + callback(f"Region function: {validation.get('region_function', '?')}") + + if not validation.get("valid", True): + warning = validation.get("warning", "") + suggestion = validation.get("suggestion", "") + msg = f"[WARNING] Perturbation may not be appropriate for {region_name}." + if warning: + msg += f"\n Reason: {warning}" + if suggestion: + msg += f"\n Suggestion: {suggestion}" + if callback: + callback(msg) + print(msg) + # Still proceed but note the warning + elif validation.get("warning"): + if callback: + callback(f"Note: {validation['warning']}") + + current = self.states.get(region_name, "unknown state") + + prompt = ChatPromptTemplate.from_template( + """You are a neuroscience expert. A brain region's state needs to be modified. + +Region: {region} +Current state: {current} +Modification requested: {modification} + +Write a new SHORT (1-2 sentences) specific state description that incorporates +the requested modification while staying neuroscientifically plausible. +Only output the new state description, nothing else.""" + ) + + llm = ChatOpenAI(model=self.model, temperature=0.3, max_tokens=200) + chain = prompt | llm | StrOutputParser() + + self._debug_prompt("alter_region_state", + prompt.format(region=region_name, current=current, + modification=modification)) + + try: + new_state = chain.invoke({ + "region": region_name, + "current": current, + "modification": modification, + }).strip() + self.states[region_name] = new_state + self.save() + return new_state + except Exception as e: + return f"(alter failed: {e})" + + def propagate_through_graph(self, source_region: str, + connections: list[dict], + A, # numpy ndarray (R x R) + roi_names: list[str], + callback=None) -> dict[str, str]: + """Propagate perturbation through the connectivity graph, depth by depth. + + For each affected region individually: + 1. Determine if the incoming signal is strong or weak (relative to that + region's other connections) + 2. Find additional un-perturbed regions strongly connected to this target + for context + 3. Ask GPT: given region X's function, its previous state, the incoming + signal from Y (strong/weak), and contextual connections — what does + X's state change to? + + Processes depth-1 targets first, then depth-2 using already-updated states, + so the signal cascades realistically. + + Args: + source_region: name of the perturbed region + connections: list of dicts from get_strongest_connections, each with + target_idx, weight, abs_weight, depth, source_idx + A: full connectivity matrix (R x R numpy array) + roi_names: list of ROI names matching A's indices + callback: optional fn(status_msg) + + Returns: + Dict of {region_name: new_state} for all updated regions. + """ + import numpy as np + _ensure_ssl() + + load_dotenv() + + + llm = ChatOpenAI(model=self.model, temperature=0.3, max_tokens=300) + + # Group connections by depth + by_depth: dict[int, list[dict]] = {} + for c in connections: + by_depth.setdefault(c["depth"], []).append(c) + + # Build name->index map + name_to_idx = {n: i for i, n in enumerate(roi_names)} + + # Track before/after states and which regions were updated + before_states: dict[str, str] = {} + all_updates: dict[str, str] = {} + perturbed_idxs = set() + source_idx = name_to_idx.get(source_region) + if source_idx is not None: + perturbed_idxs.add(source_idx) + + # Diagonal-zeroed matrix for connection lookups + Ac = np.array(A, dtype=np.float32, copy=True) + np.fill_diagonal(Ac, 0.0) + + # Process depth by depth + max_depth = max(by_depth.keys()) if by_depth else 0 + for d in range(1, max_depth + 1): + depth_conns = by_depth.get(d, []) + if not depth_conns: + continue + + if callback: + callback(f"Propagating depth {d}: {len(depth_conns)} regions...") + + for c in depth_conns: + ti = c["target_idx"] + target_name = roi_names[ti] + from_idx = c["source_idx"] + from_name = roi_names[from_idx] + + # Skip if target has no state + target_prev_state = self.states.get(target_name, "") + if not target_prev_state: + target_prev_state = "unknown / not initialized" + before_states[target_name] = target_prev_state + + # Get the incoming source's current state (may have been + # updated in a previous depth iteration) + from_state = all_updates.get(from_name, + self.states.get(from_name, "unknown")) + + # ---- Determine signal strength and type ---- + # Compare this connection weight to the target's other incoming + # connections (row ti of A) + incoming_weights = np.abs(Ac[ti, :]) + incoming_weights[ti] = 0 # no self + median_incoming = float(np.median(incoming_weights[incoming_weights > 0])) \ + if np.any(incoming_weights > 0) else 0.001 + conn_abs_w = c["abs_weight"] + if conn_abs_w > median_incoming * 2.0: + strength_label = "STRONG (well above average)" + elif conn_abs_w > median_incoming * 0.8: + strength_label = "moderate" + else: + strength_label = "weak (below average)" + + # Determine excitatory vs inhibitory based on connection sign + raw_weight = c.get("weight", conn_abs_w) + if raw_weight < 0: + sign_label = "INHIBITORY (negative connection weight)" + else: + sign_label = "EXCITATORY (positive connection weight)" + + # ---- Find context: other strong un-perturbed connections ---- + context_lines = [] + top_others = np.argsort(incoming_weights)[::-1][:10] + for oi in top_others: + if int(oi) == from_idx or int(oi) in perturbed_idxs: + continue + oi = int(oi) + other_name = roi_names[oi] + other_state = self.states.get(other_name, "") + if not other_state: + continue + other_w = float(incoming_weights[oi]) + if other_w < conn_abs_w * 0.3: + break # only include meaningfully strong ones + context_lines.append( + f"- {other_name} (connection weight {other_w:.4f}): " + f"state = \"{other_state}\"" + ) + if len(context_lines) >= 3: + break + + context_text = "" + if context_lines: + context_text = ( + "\n\nAdditional context — other strong connections to this region " + "(NOT perturbed, their states remain stable):\n" + + "\n".join(context_lines) + ) + + # ---- GPT call for this single region ---- + prompt = ChatPromptTemplate.from_template( + """You are a computational neuroscientist analyzing intrinsic information flow in a resting-state brain network (rs-fMRI effective connectivity). + +TARGET REGION: {target} +TARGET PREVIOUS STATE: {target_state} + +INCOMING SIGNAL FROM: {source} +INCOMING SIGNAL STRENGTH: {strength} (connection weight: {weight:.4f}) +CONNECTION TYPE: {sign} +SOURCE REGION'S CURRENT STATE: "{source_state}" +{context} +CRITICAL INSTRUCTIONS: +1. NO SEMANTIC ECHOING: Do not simply copy the semantic concept of the source region. You must TRANSLATE the incoming signal into the strict anatomical and functional domain of the TARGET REGION. If the source is about "visual beauty", the motor cortex should NOT start "appreciating beauty" — it should show changes in motor readiness or postural tone. +2. RESTING-STATE CONTEXT: The connectivity data reflects intrinsic resting-state dynamics. Information flow here represents spontaneous internal cognition or modulation of the target region's resting equilibrium. +3. INHIBITORY vs EXCITATORY: If the connection is INHIBITORY, the incoming signal SUPPRESSES or DAMPENS the target region's activity. If EXCITATORY, it AMPLIFIES or FACILITATES the target's function. This fundamentally changes the nature of the state change. +4. INTRINSIC DYNAMICS: Focus on how the target region's OWN function shifts, not on relaying the source's content. +5. Output ONLY the precise description of the target region's new state (1-2 sentences). No conversational filler. + +Note: connection weights are directed influence estimates from resting-state fMRI — interpret propagation as the most likely influence pattern, not literal neural signal timing.""" + ) + + chain = prompt | llm | StrOutputParser() + + self._debug_prompt("propagate_through_graph", + prompt.format(target=target_name, + target_state=target_prev_state, + strength=strength_label, + sign=sign_label, + source=from_name, + source_state=from_state, + weight=c["abs_weight"], + context=context_text)) + + try: + new_state = chain.invoke({ + "target": target_name, + "target_state": target_prev_state, + "strength": strength_label, + "sign": sign_label, + "source": from_name, + "source_state": from_state, + "weight": c["abs_weight"], + "context": context_text, + }).strip() + all_updates[target_name] = new_state + perturbed_idxs.add(ti) + + if callback: + callback(f" {target_name}: {new_state}") + + except Exception as e: + print(f"[brain-state] propagation error for {target_name}: {e}") + + # Record source before state too + before_states[source_region] = self.states.get(source_region, "unknown") + + # Apply all updates + for name, new_state in all_updates.items(): + self.states[name] = new_state + self.save() + + # Attach before_states so summarize_changes can use them + self._last_before_states = before_states + + return all_updates + + def propagate_through_regions(self, source_region: str, affected_regions: list[str], + flow_strengths: dict[str, float] | None = None, + callback=None) -> dict[str, str]: + """Simpler propagation for MDN flow mode (no connectivity matrix). + + Uses flow strengths as a proxy for connection weights. Processes regions + in order of the flow path (which is already serial). + """ + _ensure_ssl() + + load_dotenv() + + + llm = ChatOpenAI(model=self.model, temperature=0.3, max_tokens=300) + + source_state = self.states.get(source_region, "unknown") + all_updates = {} + before_states = {source_region: source_state} + + # Process regions in flow order (serial propagation) + prev_name = source_region + prev_state = source_state + + if flow_strengths and affected_regions: + all_strengths = [flow_strengths.get(n, 0.0) for n in affected_regions] + max_s = max(all_strengths) if all_strengths else 1.0 + else: + max_s = 1.0 + + for name in affected_regions: + target_state = self.states.get(name, "unknown") + before_states[name] = target_state + + strength = flow_strengths.get(name, 0.0) if flow_strengths else 0.0 + if max_s > 0: + rel_strength = strength / max_s + if rel_strength > 0.6: + strength_label = "STRONG" + elif rel_strength > 0.3: + strength_label = "moderate" + else: + strength_label = "weak" + else: + strength_label = "moderate" + + prompt = ChatPromptTemplate.from_template( + """You are a computational neuroscientist analyzing intrinsic information flow in a resting-state brain network. + +TARGET REGION: {target} +TARGET PREVIOUS STATE: {target_state} + +INCOMING SIGNAL FROM: {source} +INCOMING SIGNAL STRENGTH: {strength} +SOURCE REGION'S CURRENT STATE: "{source_state}" + +CRITICAL INSTRUCTIONS: +1. NO SEMANTIC ECHOING: Do not simply copy the semantic concept of the source region. You must TRANSLATE the incoming signal into the strict anatomical and functional domain of the TARGET REGION ({target}). For example, if the source is a visual area processing "edge detection" and the target is a motor area, do NOT say the motor area is doing "edge detection" — describe how the motor area's OWN function shifts in response. +2. RESTING-STATE CONTEXT: This is intrinsic resting-state dynamics, not task-driven activity. Describe subtle modulations, not dramatic activations. +3. INTRINSIC DYNAMICS: Focus on how {target}'s OWN function shifts given the incoming signal. The target region does what IT does, influenced by the source — not what the source does. + +Note: signal strength here is a proxy from the flow field magnitude, not a measured biological quantity. Interpret as the most likely direction of influence, not neural propagation timing. + +Output ONLY the precise description of {target}'s new state (1-2 sentences). No labels, no prefixes.""" + ) + + chain = prompt | llm | StrOutputParser() + + self._debug_prompt("propagate_through_regions", + prompt.format(target=name, target_state=target_state, + strength=strength_label, source=prev_name, + source_state=prev_state)) + + try: + new_state = chain.invoke({ + "target": name, + "target_state": target_state, + "strength": strength_label, + "source": prev_name, + "source_state": prev_state, + }).strip() + all_updates[name] = new_state + if callback: + callback(f" {name}: {new_state}") + # Next hop uses this updated state as source + prev_name = name + prev_state = new_state + except Exception as e: + print(f"[brain-state] propagation error for {name}: {e}") + + # Apply + for name, new_state in all_updates.items(): + self.states[name] = new_state + self.save() + + self._last_before_states = before_states + return all_updates + + def summarize_changes(self, updates: dict[str, str], + source_region: str) -> str: + """Summarize what changed: show before/after states (without revealing the + perturbation) and ask GPT to build a coherent picture. + + Args: + updates: dict of {region_name: new_state} + source_region: the region that was perturbed + + Returns: + Human-readable coherent summary. + """ + _ensure_ssl() + + load_dotenv() + + + # Get before states (saved by propagation methods) + before = getattr(self, '_last_before_states', {}) + + # Build separate before and after state maps + all_regions = [source_region] + [n for n in updates if n != source_region] + before_lines = [] + after_lines = [] + for name in all_regions: + prev = before.get(name, "unknown") + current = self.states.get(name, updates.get(name, "unknown")) + before_lines.append(f"- {name}: \"{prev}\"") + after_lines.append(f"- {name}: \"{current}\"") + + llm = ChatOpenAI(model=self.model, temperature=0.3, max_tokens=800) + + prompt = ChatPromptTemplate.from_template( + """You are a network neuroscientist analyzing a macroscopic shift in resting-state brain activity based on effective connectivity changes. + +INITIAL BRAIN STATE MAP: +{comparisons_before} + +POST-PROPAGATION BRAIN STATE MAP: +{comparisons_after} + +CRITICAL INSTRUCTIONS: +1. DO NOT list the regions or compare them one by one. +2. This is a resting-state brain network — interpret changes as shifts in intrinsic functional organization, not external stimulus-response narratives. +3. FOCUS ON INTRINSIC STATES: Synthesize this data into ONE coherent paragraph explaining the overall shift in the subject's internal cognitive, emotional, or physiological baseline. +4. NETWORK LEVEL INTEGRATION: Identify the broad functional domains driving the new equilibrium and describe the holistic network-level transition based purely on the provided state changes. + +Note: the underlying connectivity is directed effective connectivity from resting-state fMRI — read the overall shift as a change in inter-regional influence patterns, not a literal neural event cascade. + +Provide your coherent resting-state network insight below:""" + ) + + chain = prompt | llm | StrOutputParser() + + self._debug_prompt("summarize_changes", + prompt.format(comparisons_before="\n".join(before_lines), + comparisons_after="\n".join(after_lines))) + + try: + return chain.invoke({ + "comparisons_before": "\n".join(before_lines), + "comparisons_after": "\n".join(after_lines), + }).strip() + except Exception as e: + return f"(summary failed: {e})" + + def generate_flow_story(self, updates: dict[str, str], + source_region: str, + connections: list[dict] | None = None) -> str: + """Generate a narrative story of how information flowed through the brain. + + Unlike summarize_changes (which gives a holistic snapshot), this tells + the story of the signal's journey: where it started, how each region + processed and transformed it, and what the downstream effects were. + + Args: + updates: dict of {region_name: new_state} + source_region: the origin region + connections: optional list of connection dicts (with depth, weight info) + + Returns: + A narrative paragraph describing the information flow journey. + """ + _ensure_ssl() + + load_dotenv() + + + before = getattr(self, '_last_before_states', {}) + + # Build ordered flow description + flow_steps = [] + source_before = before.get(source_region, "unknown") + source_after = self.states.get(source_region, + updates.get(source_region, "unknown")) + flow_steps.append( + f"ORIGIN — {source_region}: \"{source_before}\" -> \"{source_after}\"" + ) + + # Order by depth if connections available + if connections: + by_depth: dict[int, list] = {} + for c in connections: + name = c.get("target_name", "") + if not name and "target_idx" in c: + continue + by_depth.setdefault(c.get("depth", 1), []).append(c) + for d in sorted(by_depth.keys()): + for c in by_depth[d]: + name = c.get("target_name", "") + if name in updates: + prev = before.get(name, "unknown") + sign = "excitatory" if c.get("weight", 0) >= 0 else "inhibitory" + flow_steps.append( + f"DEPTH {d} ({sign}) — {name}: \"{prev}\" -> \"{updates[name]}\"" + ) + else: + for name, new_state in updates.items(): + if name == source_region: + continue + prev = before.get(name, "unknown") + flow_steps.append(f"STEP — {name}: \"{prev}\" -> \"{new_state}\"") + + flow_text = "\n".join(flow_steps) + + llm = ChatOpenAI(model=self.model, temperature=0.4, max_tokens=800) + + prompt = ChatPromptTemplate.from_template( + """You are a science writer narrating how a signal traveled through a resting-state brain network. + +SIGNAL FLOW PATH (in order of propagation): +{flow_path} + +Write a SHORT narrative story (2-3 paragraphs) of how the information traveled through the brain: +- Start with where the signal originated and what it carried +- Describe how each region it reached processed and TRANSFORMED the signal according to its own function +- Highlight how the signal's meaning changed as it moved through different functional domains +- End with the overall effect on the brain's resting state + +RULES: +- Do NOT list regions mechanically — weave them into a flowing narrative +- Use concrete, vivid language about what each region actually does +- Show how the signal was transformed at each hop, not just passed along +- Keep it grounded in neuroscience but accessible to a general audience""" + ) + + chain = prompt | llm | StrOutputParser() + + self._debug_prompt("generate_flow_story", + prompt.format(flow_path=flow_text)) + + try: + return chain.invoke({"flow_path": flow_text}).strip() + except Exception as e: + return f"(story generation failed: {e})" diff --git a/src/colormaps.py b/src/colormaps.py new file mode 100644 index 0000000000000000000000000000000000000000..f7402f611f541e9bb863081af274257ded04e8f1 --- /dev/null +++ b/src/colormaps.py @@ -0,0 +1,50 @@ +"""Colormap utilities: Turbo, Rainbow, and bipolar white-center.""" + +import numpy as np + + +def turbo_rgb01(t): + t = np.clip(np.asarray(t, np.float32), 0, 1) + r = (34.61 + t * (1172.33 + t * (-10793.56 + t * (33300.12 + t * (-38345.17 + 14829.80 * t))))) / 255.0 + g = (23.31 + t * (557.33 + t * (1225.33 + t * (-3574.96 + t * 2199.29)))) / 255.0 + b = (27.20 + t * (3211.10 + t * (-15327.97 + t * (34592.87 + t * (-30538.66 + 9347.97 * t))))) / 255.0 + rgb = np.stack([r, g, b], axis=-1) + return (np.clip(rgb, 0, 1) * 255 + 0.5).astype(np.uint8) + + +def rainbow_rgb01(t): + t = np.clip(np.asarray(t, np.float32), 0.0, 1.0) + h = t * 4.0 + i = np.floor(h).astype(np.int32) + f = h - i + q = 1.0 - f + i = np.clip(i, 0, 3) + zeros = np.zeros_like(t) + ones = np.ones_like(t) + r = np.choose(i, [zeros, zeros, f, ones]) + g = np.choose(i, [f, ones, ones, q]) + b = np.choose(i, [ones, q, zeros, zeros]) + start = (t <= 0.0) + end = (t >= 1.0) + if np.any(start): + r[start], g[start], b[start] = 0.0, 0.0, 1.0 + if np.any(end): + r[end], g[end], b[end] = 1.0, 0.0, 0.0 + rgb = np.stack([r, g, b], axis=-1) + return (np.clip(rgb, 0, 1) * 255 + 0.5).astype(np.uint8) + + +def bicolor_white_center(dent_vals, pos=(255, 120, 0), neg=(0, 100, 255)): + d = np.asarray(dent_vals, np.float32) + a = np.abs(d) + scale = float(np.percentile(a, 97)) if a.size else 1.0 + if not np.isfinite(scale) or scale <= 1e-12: + scale = 1.0 + t = np.clip(a / scale, 0.0, 1.0)[:, None] + white = np.array([[255, 255, 255]], np.float32) + pos_c = np.array(pos, np.float32)[None, :] + neg_c = np.array(neg, np.float32)[None, :] + out = np.where(d[:, None] >= 0.0, + (1.0 - t) * white + t * pos_c, + (1.0 - t) * white + t * neg_c) + return out.astype(np.uint8) diff --git a/src/extra_parcellation.py b/src/extra_parcellation.py new file mode 100644 index 0000000000000000000000000000000000000000..9e61a34cca4bc525a9d3ccffb5142042301d1de2 --- /dev/null +++ b/src/extra_parcellation.py @@ -0,0 +1,315 @@ +"""Optional extra brain parcellation support. + +Loads volumetric NIfTI parcellation atlases (e.g., Brainnetome, AAL3, Schaefer) +and provides point-in-region queries + on-demand mesh generation via marching cubes. + +Dependencies: nibabel, scikit-image (optional — install with: + pip install nibabel scikit-image +) + +Usage: + from src.extra_parcellation import ExtraParcellation + + extra = ExtraParcellation( + nifti_path="data/extra_parcellation/BN_Atlas_246_1mm.nii.gz", + label_map_path="data/extra_parcellation/BN_Atlas_labels.json" # optional + ) + extra.load() + + # Query a point in MNI space + result = extra.get_region_at_point(np.array([-30.0, 15.0, 50.0])) + # -> {"label_id": 42, "name": "SFG_R_7_2", "volume_mm3": 1523.0} + + # Get VTK mesh for visualization + polydata = extra.get_region_mesh(42) +""" + +import json +from pathlib import Path +from typing import Optional + +import numpy as np + +try: + import nibabel as nib + HAS_NIBABEL = True +except ImportError: + HAS_NIBABEL = False + +try: + from skimage.measure import marching_cubes + HAS_SKIMAGE = True +except ImportError: + HAS_SKIMAGE = False + +try: + import vtk + from vtkmodules.util.numpy_support import numpy_to_vtk + HAS_VTK = True +except ImportError: + HAS_VTK = False + + +class ExtraParcellation: + """Volumetric NIfTI parcellation atlas for additional region granularity. + + Designed to work alongside the primary Allen atlas meshes. When both + parcellations cover a point, the caller (FlowMeshOverlay) can decide + which to use (typically the smaller/more specific one). + """ + + def __init__(self, nifti_path: Path | str, + label_map_path: Path | str | None = None): + """ + Args: + nifti_path: Path to NIfTI volume (.nii or .nii.gz) with integer labels. + label_map_path: Optional JSON mapping label IDs to human-readable names. + Format: {"1": "Region A", "2": "Region B", ...} + If omitted, labels will be "Region_". + """ + self.nifti_path = Path(nifti_path) + self.label_map_path = Path(label_map_path) if label_map_path else None + + # Set after load() + self._data: Optional[np.ndarray] = None # (X,Y,Z) int label volume + self._affine: Optional[np.ndarray] = None # 4x4 voxel-to-world + self._inv_affine: Optional[np.ndarray] = None # 4x4 world-to-voxel + self._voxel_vol: float = 1.0 # mm^3 per voxel + self._label_names: dict[int, str] = {} # label_id -> name + self._label_volumes: dict[int, float] = {} # label_id -> volume in mm^3 + self._mesh_cache: dict[int, object] = {} # label_id -> vtkPolyData + self._unique_labels: set[int] = set() + + def load(self) -> bool: + """Load the NIfTI volume and label map. + + Returns True on success, False on failure (with printed warning). + """ + if not HAS_NIBABEL: + print("[extra-parcellation] nibabel not installed. " + "Install with: pip install nibabel") + return False + + if not self.nifti_path.exists(): + print(f"[extra-parcellation] File not found: {self.nifti_path}") + return False + + try: + img = nib.load(str(self.nifti_path)) + self._data = np.asarray(img.dataobj, dtype=np.int32) + self._affine = img.affine.copy() + self._inv_affine = np.linalg.inv(self._affine) + + # Compute voxel volume from affine + vox_sizes = np.sqrt(np.sum(self._affine[:3, :3] ** 2, axis=0)) + self._voxel_vol = float(np.prod(vox_sizes)) + + # Find all unique labels (excluding 0 = background) + self._unique_labels = set(np.unique(self._data)) - {0} + + # Pre-compute volumes + for label_id in self._unique_labels: + voxel_count = int(np.sum(self._data == label_id)) + self._label_volumes[label_id] = voxel_count * self._voxel_vol + + # Load label names + self._label_names = {} + if self.label_map_path and self.label_map_path.exists(): + try: + raw = json.loads(self.label_map_path.read_text(encoding="utf-8")) + for k, v in raw.items(): + self._label_names[int(k)] = str(v) + except Exception as e: + print(f"[extra-parcellation] Warning: could not load label map: {e}") + + # Fill in missing names + for label_id in self._unique_labels: + if label_id not in self._label_names: + self._label_names[label_id] = f"Region_{label_id}" + + print(f"[extra-parcellation] Loaded {self.nifti_path.name}: " + f"{len(self._unique_labels)} regions, " + f"voxel size {vox_sizes[0]:.1f}x{vox_sizes[1]:.1f}x{vox_sizes[2]:.1f} mm, " + f"volume shape {self._data.shape}") + return True + + except Exception as e: + print(f"[extra-parcellation] Failed to load {self.nifti_path}: {e}") + return False + + def is_loaded(self) -> bool: + return self._data is not None + + def _world_to_voxel(self, point: np.ndarray) -> np.ndarray: + """Convert world (MNI) coordinates to voxel indices.""" + pt4 = np.array([point[0], point[1], point[2], 1.0]) + vox = self._inv_affine @ pt4 + return vox[:3] + + def get_region_at_point(self, point: np.ndarray) -> Optional[dict]: + """Look up which region a world-space point falls in. + + Args: + point: (3,) array in world/MNI coordinates (mm) + + Returns: + dict with {"label_id": int, "name": str, "volume_mm3": float} + or None if point is outside any labeled region. + """ + if self._data is None: + return None + + vox = self._world_to_voxel(point) + ix, iy, iz = int(round(vox[0])), int(round(vox[1])), int(round(vox[2])) + + # Bounds check + if (ix < 0 or iy < 0 or iz < 0 or + ix >= self._data.shape[0] or + iy >= self._data.shape[1] or + iz >= self._data.shape[2]): + return None + + label = int(self._data[ix, iy, iz]) + if label == 0: + return None + + return { + "label_id": label, + "name": self._label_names.get(label, f"Region_{label}"), + "volume_mm3": self._label_volumes.get(label, 0.0), + } + + def get_nearby_region(self, point: np.ndarray, search_radius: int = 2) -> Optional[dict]: + """Search nearby voxels if exact point has no label. + + Args: + point: (3,) world coordinates + search_radius: search cube half-size in voxels + + Returns: + dict or None + """ + if self._data is None: + return None + + vox = self._world_to_voxel(point) + cx, cy, cz = int(round(vox[0])), int(round(vox[1])), int(round(vox[2])) + + best_label = None + best_dist = float('inf') + + for dx in range(-search_radius, search_radius + 1): + for dy in range(-search_radius, search_radius + 1): + for dz in range(-search_radius, search_radius + 1): + if dx == 0 and dy == 0 and dz == 0: + continue + ix, iy, iz = cx + dx, cy + dy, cz + dz + if (ix < 0 or iy < 0 or iz < 0 or + ix >= self._data.shape[0] or + iy >= self._data.shape[1] or + iz >= self._data.shape[2]): + continue + label = int(self._data[ix, iy, iz]) + if label == 0: + continue + dist = dx*dx + dy*dy + dz*dz + if dist < best_dist: + best_dist = dist + best_label = label + + if best_label is None: + return None + + return { + "label_id": best_label, + "name": self._label_names.get(best_label, f"Region_{best_label}"), + "volume_mm3": self._label_volumes.get(best_label, 0.0), + } + + def get_region_name(self, label_id: int) -> str: + return self._label_names.get(label_id, f"Region_{label_id}") + + def get_region_volume(self, label_id: int) -> float: + return self._label_volumes.get(label_id, 0.0) + + def get_all_labels(self) -> list[int]: + return sorted(self._unique_labels) + + def get_region_mesh(self, label_id: int): + """Generate a VTK polydata mesh for a region via marching cubes. + + Meshes are cached after first generation. Returns None if + scikit-image is not installed or the region has too few voxels. + """ + if label_id in self._mesh_cache: + return self._mesh_cache[label_id] + + if not HAS_SKIMAGE: + print("[extra-parcellation] scikit-image not installed for mesh generation. " + "Install with: pip install scikit-image") + return None + + if not HAS_VTK: + return None + + if self._data is None: + return None + + # Create binary mask + mask = (self._data == label_id).astype(np.float32) + if mask.sum() < 10: # too small for meaningful mesh + return None + + # Pad to avoid edge artifacts + padded = np.pad(mask, 1, mode='constant', constant_values=0) + + try: + verts, faces, normals, _ = marching_cubes(padded, level=0.5) + # Remove padding offset + verts = verts - 1.0 + + # Transform vertices from voxel to world space + ones = np.ones((len(verts), 1), dtype=np.float64) + verts_h = np.hstack([verts, ones]) + world_verts = (self._affine @ verts_h.T).T[:, :3] + + # Build VTK polydata + points = vtk.vtkPoints() + points.SetData(numpy_to_vtk(world_verts.astype(np.float64), deep=True)) + + cells = vtk.vtkCellArray() + for face in faces: + cells.InsertNextCell(3) + for idx in face: + cells.InsertCellPoint(int(idx)) + + polydata = vtk.vtkPolyData() + polydata.SetPoints(points) + polydata.SetPolys(cells) + + # Compute normals for smooth rendering + normal_gen = vtk.vtkPolyDataNormals() + normal_gen.SetInputData(polydata) + normal_gen.ComputePointNormalsOn() + normal_gen.Update() + polydata = normal_gen.GetOutput() + + self._mesh_cache[label_id] = polydata + return polydata + + except Exception as e: + print(f"[extra-parcellation] Mesh generation failed for label {label_id}: {e}") + return None + + def get_region_center(self, label_id: int) -> Optional[np.ndarray]: + """Get the center of mass of a region in world coordinates.""" + if self._data is None: + return None + + coords = np.argwhere(self._data == label_id) + if len(coords) == 0: + return None + + center_vox = coords.mean(axis=0) + center_world = self._affine @ np.array([*center_vox, 1.0]) + return center_world[:3] diff --git a/src/field_loader.py b/src/field_loader.py new file mode 100644 index 0000000000000000000000000000000000000000..fb541c234bacde68e7feb7ece764ea983707aa34 --- /dev/null +++ b/src/field_loader.py @@ -0,0 +1,192 @@ +"""Load MDN vector fields from metadata JSON + binary grid files.""" + +import json +import re +from pathlib import Path +from typing import Optional + +import numpy as np + + +def load_field(meta_path: Path, grid_bin_path: Optional[Path] = None) -> dict: + """ + Load MDN vector grids and metadata. Supports arbitrary K mixture components. + Returns dict with keys: G, amin, amax, mean, mus, pi, ENT, TRAIN, meta. + """ + meta = json.loads(Path(meta_path).read_text(encoding="utf-8")) + G = int(meta["grid"]) + amin = np.array(meta["axis_min"], dtype=np.float32) + amax = np.array(meta["axis_max"], dtype=np.float32) + files = dict(meta.get("files") or {}) + + def _resolve(p): + p = Path(p) + return p if p.exists() else (Path(meta_path).parent / p) + + def _load_xyz3(fname): + raw = np.fromfile(_resolve(fname), dtype=np.float32) + return raw.reshape(G, G, G, 3).astype(np.float32) + + def _load_scalar_or_channels(fname): + raw = np.fromfile(_resolve(fname), dtype=np.float32) + base = G * G * G + if raw.size == base: + return raw.reshape(G, G, G).astype(np.float32) + if raw.size % base == 0: + K = raw.size // base + return raw.reshape(G, G, G, K).astype(np.float32) + raise ValueError(f"{fname}: size {raw.size} incompatible with G^3={base}") + + # mean field + if "mean_xyz3" in files: + V_mean = _load_xyz3(files["mean_xyz3"]) + else: + if grid_bin_path is None: + grid_bin_path = Path(meta_path).with_name( + Path(meta_path).name.replace("_meta.json", ".bin")) + raw = np.fromfile(grid_bin_path, dtype=np.float32) + V_mean = raw.reshape(G, G, G, 3).astype(np.float32) + + # collect mu_k components + mu_items = [] + for k, v in files.items(): + ks = str(k).lower() + if "xyz3" in ks and "mu" in ks: + m = re.search(r'(\d+)(?!.*\d)', ks) + if m: + mu_items.append((int(m.group(1)), v)) + mu_items.sort(key=lambda kv: kv[0]) + MUS = [_load_xyz3(fname) for _, fname in mu_items] + K_mu = len(MUS) + + # pi (mixture weights) + PI = None + if "pi" in files: + PI = _load_scalar_or_channels(files["pi"]) + if PI.ndim == 3 and K_mu == 2: + PI = np.stack([PI, 1.0 - PI], axis=-1).astype(np.float32) + + # entropy + ENT = None + if "entropy" in files: + ENT = _load_scalar_or_channels(files["entropy"]) + if ENT.ndim != 3: + raise ValueError(f"'entropy' must be (G,G,G); got {ENT.shape}") + + # training points + train_pts = None + tp_key = meta.get("training_points_npy") + if tp_key: + p = _resolve(tp_key) + if p.exists(): + train_pts = np.load(p).astype(np.float32) + + return { + "G": G, "amin": amin, "amax": amax, + "mean": V_mean, + "mus": MUS, + "pi": PI, + "ENT": ENT, + "TRAIN": train_pts, + "meta": meta, + } + + +class TriLinearSampler: + """Trilinear interpolation of 3D vector or scalar fields at arbitrary points.""" + + def __init__(self, grid_xyz3, amin, amax): + self.g = grid_xyz3.astype(np.float32) + self.G = grid_xyz3.shape[0] + self.amin = amin.astype(np.float32) + self.amax = amax.astype(np.float32) + span = self.amax - self.amin + self.inv_span = np.where(span > 0, 1.0 / span, 0).astype(np.float32) + + def _idx(self, P): + f = (P - self.amin) * self.inv_span * (self.G - 1) + ix = np.floor(f[:, 0]).astype(np.int32) + tx = (f[:, 0] - ix).astype(np.float32) + iy = np.floor(f[:, 1]).astype(np.int32) + ty = (f[:, 1] - iy).astype(np.float32) + iz = np.floor(f[:, 2]).astype(np.int32) + tz = (f[:, 2] - iz).astype(np.float32) + ix = np.clip(ix, 0, self.G - 2) + iy = np.clip(iy, 0, self.G - 2) + iz = np.clip(iz, 0, self.G - 2) + return ix, iy, iz, tx, ty, tz + + def sample_vec(self, P): + ix, iy, iz, tx, ty, tz = self._idx(P) + g = self.g + v000 = g[ix, iy, iz] + v100 = g[ix + 1, iy, iz] + v010 = g[ix, iy + 1, iz] + v110 = g[ix + 1, iy + 1, iz] + v001 = g[ix, iy, iz + 1] + v101 = g[ix + 1, iy, iz + 1] + v011 = g[ix, iy + 1, iz + 1] + v111 = g[ix + 1, iy + 1, iz + 1] + vx00 = v000 * (1 - tx)[:, None] + v100 * tx[:, None] + vx10 = v010 * (1 - tx)[:, None] + v110 * tx[:, None] + vx01 = v001 * (1 - tx)[:, None] + v101 * tx[:, None] + vx11 = v011 * (1 - tx)[:, None] + v111 * tx[:, None] + vxy0 = vx00 * (1 - ty)[:, None] + vx10 * ty[:, None] + vxy1 = vx01 * (1 - ty)[:, None] + vx11 * ty[:, None] + out = vxy0 * (1 - tz)[:, None] + vxy1 * tz[:, None] + return out.astype(np.float32) + + def sample_scalar(self, S, P): + ix, iy, iz, tx, ty, tz = self._idx(P) + s000 = S[ix, iy, iz] + s100 = S[ix + 1, iy, iz] + s010 = S[ix, iy + 1, iz] + s110 = S[ix + 1, iy + 1, iz] + s001 = S[ix, iy, iz + 1] + s101 = S[ix + 1, iy, iz + 1] + s011 = S[ix, iy + 1, iz + 1] + s111 = S[ix + 1, iy + 1, iz + 1] + sx00 = s000 * (1 - tx) + s100 * tx + sx10 = s010 * (1 - tx) + s110 * tx + sx01 = s001 * (1 - tx) + s101 * tx + sx11 = s011 * (1 - tx) + s111 * tx + return (sx00 * (1 - ty) + sx10 * ty) * (1 - tz) + (sx01 * (1 - ty) + sx11 * ty) * tz + + +def load_points_any(path: Path) -> np.ndarray: + """Load point cloud from .npy, .obj, or .ply file.""" + p = Path(path) + if p.suffix.lower() == ".npy": + return np.asarray(np.load(p), np.float32) + if p.suffix.lower() == ".obj": + pts = [] + with open(p, "r", encoding="utf-8", errors="ignore") as f: + for line in f: + if not line or line[0] != 'v' or (len(line) > 1 and line[1] not in (' ', '\t')): + continue + parts = line.strip().split() + if len(parts) >= 4: + pts.append([float(parts[1]), float(parts[2]), float(parts[3])]) + return np.asarray(pts, np.float32) + if p.suffix.lower() == ".ply": + with open(p, "r", encoding="utf-8", errors="ignore") as f: + header = [] + while True: + line = f.readline() + if not line: + raise ValueError("Invalid PLY (no end_header)") + header.append(line.strip()) + if line.strip() == "end_header": + break + nverts = 0 + for h in header: + if h.startswith("element vertex"): + nverts = int(h.split()[-1]) + break + pts = [] + for _ in range(nverts): + parts = f.readline().strip().split() + if len(parts) >= 3: + pts.append([float(parts[0]), float(parts[1]), float(parts[2])]) + return np.asarray(pts, np.float32) + raise ValueError(f"Unsupported point format: {p.suffix}") diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000000000000000000000000000000000000..15ba06da988500b25f0f837c2c0e0493de3c0975 --- /dev/null +++ b/src/main.py @@ -0,0 +1,1098 @@ +#!/usr/bin/env python3 +"""mindVisualizer — Brain flow particle visualizer with probe analysis. + +Filtered and modular version of simulate_mdn_flow.py, focused on: + - MDN particle flow with OOS seeding and far-from-OOS death acceleration + - Flow mesh overlay (all available brain region meshes, key: q/w) + - Probe system: single, multi-probe, and branching modes + - GPT-powered region transition analysis (RAG or direct) + - Brain region state database with perturbation propagation + +Run with no arguments for defaults: + python -m src.main + +Key bindings: + space pause/resume + f cycle field (mean / gated / comp1..6) + v cycle color mode (SPEED / ENTROPY / DELTA_ENTROPY / DIR_DELTA_ENTROPY) + m cycle colormap (TURBO / RAINBOW) + q / w cycle flow mesh (prev / next) + \\ (backslash) hide flow mesh + 9 / 0 mesh opacity -/+ + [ / ] dt -/+ + + / - speed scale +/- + 1 / 2 particle lifetime -/+ + 7 / 8 particle opacity -/+ + y toggle speed filter + u filter direction (top/bottom) + z / x filter fraction -/+ + o toggle OOS overlay + g place probe (click in scene first) + G (shift+g) analyze probe path with GPT (async) + b toggle branching mode + n cycle multi-probe count (1/4/8) + s initialize brain states (prompts in console) + S (shift+s) propagate state through probe path + c clear probe + Escape quit +""" + +import argparse +import os +import sys +import threading +from pathlib import Path + +import numpy as np +import vtk +from vtkmodules.util.numpy_support import numpy_to_vtk +from vtkmodules.vtkCommonCore import vtkUnsignedCharArray +from vtkmodules.vtkInteractionStyle import vtkInteractorStyleTrackballCamera +from vtkmodules.vtkFiltersModeling import vtkOutlineFilter +from scipy.spatial import cKDTree + +from .field_loader import load_field, TriLinearSampler, load_points_any +from .colormaps import turbo_rgb01, rainbow_rgb01, bicolor_white_center +from .mesh_overlay import FlowMeshOverlay +from .probe import ProbeSystem +from .region_analyzer import (analyze_probe_path, analyze_with_gpt, + format_transitions_text) +from .brain_state import BrainStateDB + +# ---------- paths (relative to project root) ---------- +PROJECT_ROOT = Path(__file__).resolve().parent.parent +DATA_DIR = PROJECT_ROOT / "data" +MDN_DIR = DATA_DIR / "mdn" +MESH_DIR = DATA_DIR / "meshes" +ALIGNMENT_FILE = DATA_DIR / "brain_alignment.json" +DEFAULT_META = MDN_DIR / "mdn_particles_rdcim_teacher_edge_hq_grid125_meta.json" +DEFAULT_OOS = MDN_DIR / "mdn_particles_rdcim_teacher_edge_hq_training_points.npy" +DEFAULT_EXTRA_PARCELLATION = DATA_DIR / "extra_parcellation" / "combined_atlas.nii.gz" +DEFAULT_EXTRA_LABELS = DATA_DIR / "extra_parcellation" / "combined_atlas_labels.json" + + +# ---------- utility functions ---------- + +def lattice_positions(n_per_axis, amin, amax, margin=0.02, jitter=0.012, seed=42): + rng = np.random.default_rng(seed) + fx = np.linspace(margin, 1.0 - margin, n_per_axis, dtype=np.float32) + X, Y, Z = np.meshgrid(fx, fx, fx, indexing="ij") + P = np.stack([X, Y, Z], axis=-1).reshape(-1, 3) + if jitter > 0: + P += (rng.random(P.shape, dtype=np.float32) - 0.5) * 2.0 * (jitter / max(n_per_axis - 1, 1)) + P = np.clip(P, 0, 1) + span = (amax - amin).astype(np.float32) + return (amin + P * span).astype(np.float32) + + +def wrap_inside(P, amin, amax, margin_frac=0.0): + span = amax - amin + offset = amin + margin_frac * span + inner = span * (1.0 - 2.0 * margin_frac) + D = P - offset + D = (D % inner + inner) % inner + return offset + D + + +def build_cloud(P_init, point_size=2.0, rgba=None, opacity=1.0): + N = len(P_init) + vtk_points = vtk.vtkPoints() + vtk_points.SetData(numpy_to_vtk(P_init, deep=True)) + pd = vtk.vtkPolyData() + pd.SetPoints(vtk_points) + verts = vtk.vtkCellArray() + verts.Allocate(N) + verts.InsertNextCell(N) + for i in range(N): + verts.InsertCellPoint(i) + pd.SetVerts(verts) + + colors = vtkUnsignedCharArray() + if rgba is not None: + C = rgba.astype(np.uint8, copy=False) + else: + C = np.tile(np.array([[255, 255, 255, 48]], np.uint8), (N, 1)) + colors.SetName("RGBA") + colors.SetNumberOfComponents(4) + colors.SetNumberOfTuples(N) + colors.DeepCopy(numpy_to_vtk(C, deep=True)) + pd.GetPointData().SetScalars(colors) + + mapper = vtk.vtkPolyDataMapper() + mapper.SetInputData(pd) + mapper.SetColorModeToDirectScalars() + mapper.ScalarVisibilityOn() + mapper.SetScalarModeToUsePointData() + + actor = vtk.vtkActor() + actor.SetMapper(mapper) + actor.GetProperty().SetPointSize(point_size) + actor.GetProperty().SetOpacity(float(opacity)) + actor.GetProperty().LightingOff() + actor.GetProperty().SetInterpolationToFlat() + return vtk_points, colors, pd, actor + + +def compute_fraction_mask(vals, frac, top): + N = len(vals) + if N == 0: + return np.zeros(0, dtype=bool) + frac = float(np.clip(frac, 0.0, 1.0)) + k = int(np.ceil(N * frac)) + if k <= 0: + return np.zeros(N, dtype=bool) + if k >= N: + return np.ones(N, dtype=bool) + mask = np.zeros(N, dtype=bool) + if top: + idx = np.argpartition(vals, N - k)[N - k:] + else: + idx = np.argpartition(vals, k)[:k] + mask[idx] = True + return mask + + +def densify_oos_surface(base_pts, full_oos_pts, amin, amax, extra_count, + k=16, jitter_frac=0.4, power=2.0, seed=2025): + if extra_count <= 0 or len(base_pts) == 0 or len(full_oos_pts) == 0: + return np.zeros((0, 3), np.float32) + cloud = np.asarray(full_oos_pts, dtype=np.float32) + m_add = min(extra_count, 500_000) + eps = 1e-9 + k_eff = int(max(3, min(k, len(cloud)))) + tree = cKDTree(cloud) + dists, _ = tree.query(base_pts, k=k_eff) + if dists.ndim == 1: + dists = dists[:, None] + r_k = dists[:, -1].astype(np.float32) + eps + med = float(np.median(r_k)) + eps + w = np.power(r_k / med, float(power)).astype(np.float64) + sw = float(np.sum(w)) + if not np.isfinite(sw) or sw <= 0.0: + return np.zeros((0, 3), np.float32) + w /= sw + rng = np.random.default_rng(seed) + sel = rng.choice(len(base_pts), size=m_add, replace=True, p=w) + extras = np.zeros((m_add, 3), np.float32) + for j, i in enumerate(sel): + s_loc = float(r_k[i]) * float(jitter_frac) + extras[j] = base_pts[i] + rng.standard_normal(3).astype(np.float32) * s_loc + return np.clip(extras, amin, amax).astype(np.float32) + + +def add_window_legend(ren, lines, font_px=13): + txt = "\n".join(lines) + ta = vtk.vtkTextActor() + ta.SetInput(txt) + tp = ta.GetTextProperty() + tp.SetColor(1, 1, 1) + tp.SetFontSize(int(font_px)) + tp.SetOpacity(0.92) + tp.SetFontFamilyToCourier() + ta.SetPosition(10, 10) + ren.AddActor(ta) + return ta + + +class VtkTextOverlay: + """Scrolling text log + GPT explanation panel in the VTK window.""" + + def __init__(self, ren: vtk.vtkRenderer, max_lines: int = 8): + self._ren = ren + self._max_lines = max_lines + self._log_lines: list[str] = [] + + self._log_actor = vtk.vtkTextActor() + self._log_actor.SetInput("") + tp = self._log_actor.GetTextProperty() + tp.SetColor(1.0, 0.9, 0.3) + tp.SetFontSize(13) + tp.SetOpacity(0.95) + tp.SetFontFamilyToCourier() + tp.SetJustificationToRight() + tp.SetVerticalJustificationToTop() + self._log_actor.GetPositionCoordinate().SetCoordinateSystemToNormalizedDisplay() + self._log_actor.GetPositionCoordinate().SetValue(0.99, 0.97) + ren.AddActor(self._log_actor) + + self._gpt_actor = vtk.vtkTextActor() + self._gpt_actor.SetInput("") + tp2 = self._gpt_actor.GetTextProperty() + tp2.SetColor(0.8, 1.0, 0.8) + tp2.SetFontSize(14) + tp2.SetOpacity(0.95) + tp2.SetFontFamilyToCourier() + tp2.SetJustificationToRight() + tp2.SetVerticalJustificationToTop() + self._gpt_actor.GetPositionCoordinate().SetCoordinateSystemToNormalizedDisplay() + self._gpt_actor.GetPositionCoordinate().SetValue(0.99, 0.70) + self._gpt_actor.VisibilityOff() + ren.AddActor(self._gpt_actor) + + def add_log(self, text: str): + self._log_lines.append(text) + if len(self._log_lines) > self._max_lines: + self._log_lines = self._log_lines[-self._max_lines:] + self._log_actor.SetInput("\n".join(self._log_lines)) + + def clear_log(self): + self._log_lines.clear() + self._log_actor.SetInput("") + + def show_gpt(self, text: str): + wrapped = [] + for line in text.split("\n"): + while len(line) > 60: + brk = line.rfind(" ", 0, 60) + if brk <= 0: + brk = 60 + wrapped.append(line[:brk]) + line = line[brk:].lstrip() + wrapped.append(line) + if len(wrapped) > 20: + wrapped = wrapped[:20] + ["..."] + self._gpt_actor.SetInput("\n".join(wrapped)) + self._gpt_actor.VisibilityOn() + + def hide_gpt(self): + self._gpt_actor.SetInput("") + self._gpt_actor.VisibilityOff() + + +# ---------- main ---------- + +def main(): + # Fix SSL cert path (Git on Windows sets bad path) + try: + import certifi + cert_file = certifi.where() + cur = os.environ.get("SSL_CERT_FILE", "") + if not cur or not os.path.isfile(cur): + os.environ["SSL_CERT_FILE"] = cert_file + cur2 = os.environ.get("REQUESTS_CA_BUNDLE", "") + if not cur2 or not os.path.isfile(cur2): + os.environ["REQUESTS_CA_BUNDLE"] = cert_file + except ImportError: + pass + try: + from dotenv import load_dotenv + load_dotenv(PROJECT_ROOT / ".env") + except ImportError: + pass + + ap = argparse.ArgumentParser(description="mindVisualizer - Brain flow particle visualizer") + ap.add_argument("--meta", type=Path, default=DEFAULT_META) + ap.add_argument("--oos", type=Path, default=DEFAULT_OOS) + ap.add_argument("--fps", type=int, default=60) + ap.add_argument("--stride", type=int, default=3) + ap.add_argument("--dt", type=float, default=1.0) + ap.add_argument("--max-step-frac", type=float, default=0.01) + ap.add_argument("--speed-scale", type=float, default=1.0) + ap.add_argument("--margin", type=float, default=0.0) + ap.add_argument("--respawn-jitter", type=float, default=0.015) + ap.add_argument("--oos-seed-limit", type=int, default=50000) + ap.add_argument("--oos-fill-count", type=int, default=10000) + ap.add_argument("--overlap-frac", type=float, default=0.01) + ap.add_argument("--overlap-jitter", type=float, default=0.002) + ap.add_argument("--overlap-limit", type=int, default=200000) + ap.add_argument("--no-far-from-oos", action="store_true") + ap.add_argument("--oos-dist-grid", type=int, default=96) + ap.add_argument("--oos-dist-thresh-frac", type=float, default=0.009) + ap.add_argument("--oos-dist-gamma", type=float, default=3.0) + ap.add_argument("--oos-death-boost", type=float, default=8.0) + ap.add_argument("--no-flow-mesh", action="store_true") + ap.add_argument("--no-rag", action="store_true", + help="Skip RAG, use only GPT's own knowledge") + ap.add_argument("--multi-probe", type=int, default=1, + help="Number of probes to spawn (1=single, 4=neighborhood)") + ap.add_argument("--branching", action="store_true", + help="Enable MDN component branching") + ap.add_argument("--window-size", type=int, nargs=2, default=[1200, 800]) + ap.add_argument("--hq", action="store_true", + help="Use high-quality GPT model (gpt-5.4) instead of gpt-5.4-mini") + ap.add_argument("--debug", action="store_true", + help="Print full LLM prompts to console before each call") + ap.add_argument("--extra-parcellation", type=Path, default=None, + help="Path to NIfTI parcellation file for finer subregion labels " + "(e.g., Brainnetome BN_Atlas_246_1mm.nii.gz)") + ap.add_argument("--extra-parcellation-labels", type=Path, default=None, + help="JSON label map for extra parcellation (label_id -> name)") + args = ap.parse_args() + + model = "gpt-5.4" if args.hq else "gpt-5.4-mini" + print(f"[config] LLM model: {model}") + + far_from_oos = not args.no_far_from_oos + flow_mesh_enabled = not args.no_flow_mesh + use_rag = not args.no_rag + debug_mode = args.debug + + # ---------- load field ---------- + print(f"[field] loading {args.meta} ...") + fld = load_field(args.meta) + G = fld["G"] + amin, amax = fld["amin"], fld["amax"] + V_mean = fld["mean"] + MUS = fld["mus"] + PI = fld["pi"] + ENT = fld["ENT"] + K = len(MUS) + + sampler_mean = TriLinearSampler(V_mean, amin, amax) + diag = float(np.linalg.norm(amax - amin)) + target_step = args.max_step_frac * max(diag, 1e-6) + + def interior_percentile(V, q=100.0): + mag = np.linalg.norm(V.reshape(-1, 3), axis=1) + nz = mag[mag > 0] + return float(np.percentile(nz, q)) if nz.size else 1.0 + + vmax_mean = interior_percentile(V_mean, q=100.0) + + sampler_gated = None + V_gated = None + if PI is not None and PI.ndim == 4 and PI.shape[-1] == K and K > 0: + idx = np.argmax(PI, axis=-1) + V_gated = np.zeros_like(MUS[0], dtype=np.float32) + for k_i in range(K): + mask = (idx == k_i)[..., None].astype(np.float32) + V_gated += MUS[k_i] * mask + sampler_gated = TriLinearSampler(V_gated, amin, amax) + + samplers_comp = [TriLinearSampler(mu, amin, amax) for mu in MUS] + + field_modes = ["mean"] + if sampler_gated is not None: + field_modes.append("gated") + field_modes += [f"comp{i + 1}" for i in range(K)] + field_mode = [field_modes[0]] + + sampler_by_name = {"mean": sampler_mean} + if sampler_gated is not None: + sampler_by_name["gated"] = sampler_gated + for i, s in enumerate(samplers_comp): + sampler_by_name[f"comp{i + 1}"] = s + + vmax_by_name = {"mean": vmax_mean} + if V_gated is not None: + vmax_by_name["gated"] = interior_percentile(V_gated, q=100.0) + for i, mu in enumerate(MUS): + vmax_by_name[f"comp{i + 1}"] = interior_percentile(mu, q=100.0) + + def current_sampler(): + return sampler_by_name.get(field_mode[0], sampler_mean) + def vmax_for_mode(): + return vmax_by_name.get(field_mode[0], vmax_mean) + + ent_min = ent_max = 0.0 + if ENT is not None: + ent_min, ent_max = float(np.min(ENT)), float(np.max(ENT)) + + # ---------- load OOS ---------- + oos_pts = None + if args.oos is not None: + try: + raw = load_points_any(Path(args.oos)) + if raw.size > 0: + oos_pts = raw.astype(np.float32) + print(f"[oos] loaded {len(oos_pts)} points") + except Exception as e: + print("[oos] failed to load:", e) + + # ---------- seeding ---------- + rng = np.random.default_rng(2025) + overlap_radius = float(args.overlap_frac) * max(diag, 1e-9) + overlap_sigma = float(args.overlap_jitter) * max(diag, 1e-9) + seed_mode = "grid" + seed_points = None + n_axis = None + + if fld["TRAIN"] is not None and oos_pts is not None and len(fld["TRAIN"]) > 0 and len(oos_pts) > 0: + try: + tree = cKDTree(fld["TRAIN"].astype(np.float32)) + dists, _ = tree.query(oos_pts.astype(np.float32), k=1) + cand = oos_pts[dists <= overlap_radius] + if cand.size > 0: + seed_mode = "overlap" + if args.overlap_limit and len(cand) > args.overlap_limit: + idx = rng.choice(len(cand), size=int(args.overlap_limit), replace=False) + cand = cand[idx] + seed_points = cand.astype(np.float32) + print(f"[seed] overlap: {len(seed_points)} points") + except Exception as e: + print("[seed] overlap failed:", e) + + if seed_mode == "overlap" and seed_points is not None: + if args.oos_seed_limit > 0 and len(seed_points) > args.oos_seed_limit: + prev = len(seed_points) + idx_seed = rng.choice(prev, size=args.oos_seed_limit, replace=False) + seed_points = seed_points[idx_seed] + print(f"[seed] capped to {len(seed_points)} (from {prev})") + + if args.oos_fill_count > 0 and oos_pts is not None and seed_mode == "overlap" and seed_points is not None: + extras = densify_oos_surface(base_pts=seed_points, full_oos_pts=oos_pts, + amin=amin, amax=amax, extra_count=args.oos_fill_count) + if extras is not None and len(extras) > 0: + seed_points = np.concatenate([seed_points, extras], axis=0).astype(np.float32) + print(f"[seed] oos-fill: added {len(extras)} -> total {len(seed_points)}") + + if seed_mode == "overlap" and seed_points is not None: + if overlap_sigma > 0: + J = rng.standard_normal(seed_points.shape).astype(np.float32) * overlap_sigma + P0 = np.clip(seed_points + J, amin, amax) + else: + P0 = np.clip(seed_points, amin, amax) + else: + n_axis = max(2, G // max(1, args.stride)) + P0 = lattice_positions(n_axis, amin, amax, margin=0.02, jitter=args.respawn_jitter, seed=2025) + + P = P0.copy() + Np = len(P) + _base_ttl_lo, _base_ttl_hi = max(8, 60 // 2), 60 + ttl_state = {"scale": 1.0} + + def _sample_ttl(n): + base = rng.integers(_base_ttl_lo, _base_ttl_hi + 1, size=int(n)) + return np.maximum(1, np.round(base * ttl_state["scale"])).astype(np.int32) + + ttl = _sample_ttl(Np) + ages = rng.integers(0, np.maximum(1, ttl), size=Np, dtype=np.int32) + + ent_prev = sampler_mean.sample_scalar(ENT, P).astype(np.float32) if ENT is not None else None + ent_d_ema = np.zeros(Np, np.float32) if ENT is not None else None + ent_alpha = 0.2 + + # ---------- VTK setup ---------- + init_rgba = np.tile(np.array([[255, 255, 255, 48]], np.uint8), (Np, 1)) + points, color_arr, poly, p_actor = build_cloud(P, point_size=2.0, rgba=init_rgba) + + ren = vtk.vtkRenderer() + ren.SetBackground(0, 0, 0) + ren.AddActor(p_actor) + ren.ResetCamera() + + o_actor = None + if oos_pts is not None and len(oos_pts) > 0: + rgb_oos = np.tile(np.array([[255, 255, 255]], np.uint8), (len(oos_pts), 1)) + oos_rgba = np.concatenate([rgb_oos, np.full((len(oos_pts), 1), 72, np.uint8)], axis=1) + _, _, _, o_actor = build_cloud(oos_pts, point_size=1.5, rgba=oos_rgba, opacity=72 / 255.0) + oos_visible = [False] + + # OOS distance grid + OOS_DIST = None + sampler_oosdist = None + far_oos_params = None + if far_from_oos and oos_pts is not None and len(oos_pts) > 0: + Ng = int(max(8, args.oos_dist_grid)) + print(f"[oos-dist] precomputing {Ng}^3 ...") + tree_oos = cKDTree(oos_pts.astype(np.float32)) + gridP = lattice_positions(Ng, amin, amax, margin=0.0, jitter=0.0, seed=0) + d, _ = tree_oos.query(gridP, k=1) + OOS_DIST = d.reshape(Ng, Ng, Ng).astype(np.float32) + sampler_oosdist = TriLinearSampler(np.zeros((Ng, Ng, Ng, 3), np.float32), amin, amax) + far_oos_params = { + "d0": float(args.oos_dist_thresh_frac) * diag, + "gamma": float(args.oos_dist_gamma), + "boost": float(args.oos_death_boost), + } + print(f"[oos-dist] ready: d0={far_oos_params['d0']:.4g}") + + win = vtk.vtkRenderWindow() + win.AddRenderer(ren) + win.SetSize(*args.window_size) + win.SetWindowName("mindVisualizer") + + # Flow mesh overlay + flow_mesh = None + if flow_mesh_enabled: + try: + flow_mesh = FlowMeshOverlay(ren=ren, win=win, + mesh_dir=MESH_DIR, alignment_file=ALIGNMENT_FILE) + n_regions = len(flow_mesh.get_all_region_keys()) + print(f"[flow mesh] ready ({n_regions} regions)") + # Load or build voxel label grid for fast point-in-region queries + grid_cache = DATA_DIR / "label_grid_cache.npz" + if not flow_mesh.load_label_grid(grid_cache): + print("[flow mesh] No cached label grid found — building now " + "(this takes ~30-60s, one-time only)...") + flow_mesh.build_label_grid() + flow_mesh.save_label_grid(grid_cache) + print("[flow mesh] Label grid cached for future runs.") + except Exception as e: + print("[flow mesh] disabled:", e) + flow_mesh = None + + # Optional extra parcellation (auto-detect combined atlas if no explicit path) + if flow_mesh: + _ep_nifti = args.extra_parcellation + _ep_labels = args.extra_parcellation_labels + if _ep_nifti is None and DEFAULT_EXTRA_PARCELLATION.exists(): + _ep_nifti = DEFAULT_EXTRA_PARCELLATION + if _ep_labels is None and DEFAULT_EXTRA_LABELS.exists(): + _ep_labels = DEFAULT_EXTRA_LABELS + if _ep_nifti is not None: + try: + from .extra_parcellation import ExtraParcellation + extra = ExtraParcellation(_ep_nifti, _ep_labels) + if extra.load(): + flow_mesh.set_extra_parcellation(extra) + except Exception as e: + print(f"[extra-parcellation] disabled: {e}") + + # Text overlay + text_overlay = VtkTextOverlay(ren) + + # Brain state database + brain_state_db = BrainStateDB(model=model, debug=args.debug) + + # Probe system + probe_sys = ProbeSystem(ren, win, amin, amax) + if flow_mesh is not None: + probe_sys.set_mesh_overlay(flow_mesh) + probe_sys.set_multi_count(args.multi_probe) + if args.branching and PI is not None: + probe_sys.set_branching(True, pi_field=PI, mus_samplers=samplers_comp) + + # Boundary constraint + if sampler_oosdist is not None and OOS_DIST is not None and far_oos_params is not None: + _boundary_d0 = far_oos_params["d0"] * 2.0 + + def _probe_boundary_check(pos): + d = float(sampler_oosdist.sample_scalar(OOS_DIST, pos[None, :])[0]) + return d < _boundary_d0 + + probe_sys.set_boundary_check(_probe_boundary_check) + + # Region change callback + def _on_region_change(entered, left, is_ghost=False, label=""): + tag = " [branch]" if is_ghost else "" + for name in left: + text_overlay.add_log(f"LEFT{tag}: {name}") + for name in entered: + text_overlay.add_log(f"ENTERED{tag}: {name}") + + probe_sys.set_on_region_change(_on_region_change) + + picker = vtk.vtkCellPicker() + picker.SetTolerance(0.01) + + # ---------- state ---------- + available_colour_modes = ( + ["SPEED"] + + (["ENTROPY"] if ENT is not None else []) + + (["DELTA_ENTROPY"] if ENT is not None else []) + + (["DIR_DELTA_ENTROPY"] if ENT is not None else []) + ) + colour_idx = [0] + cmap_mode = ["TURBO"] + state = {"paused": False, "dt": float(args.dt), "scale": float(args.speed_scale)} + filter_state = {"mode": "OFF", "dir_top": True, "frac": 1.0} + clip_state = {"on": False, "frac": 1.0} + mdn_alpha = [1.0] + last_rgba = [init_rgba.copy()] + probe_mode = [False] + + # Async GPT result holder + gpt_pending = {"result": None, "running": False} + + def map_rgb_from_t(t01): + return rainbow_rgb01(t01) if cmap_mode[0] == "RAINBOW" else turbo_rgb01(t01) + + def apply_colors(speed_vals, P_world, dent_vals=None, vis_mask=None): + mode = available_colour_modes[colour_idx[0]] + eps = 1e-12 + if mode == "ENTROPY" and ENT is not None: + scal = sampler_mean.sample_scalar(ENT, P_world) + t = (scal - ent_min) / max(ent_max - ent_min, eps) + base_rgb = map_rgb_from_t(np.clip(t, 0, 1)) + elif mode == "DELTA_ENTROPY" and dent_vals is not None: + a = np.abs(dent_vals) + scale = float(np.percentile(a, 97)) if a.size else 1.0 + if not np.isfinite(scale) or scale <= eps: + scale = 1.0 + base_rgb = map_rgb_from_t(np.clip(a / scale, 0, 1)) + elif mode == "DIR_DELTA_ENTROPY" and dent_vals is not None: + base_rgb = bicolor_white_center(dent_vals) + else: + smin = float(np.min(speed_vals)) if speed_vals.size else 0.0 + smax = float(np.max(speed_vals)) if speed_vals.size else 1.0 + t = (speed_vals - smin) / max(smax - smin, eps) + base_rgb = map_rgb_from_t(np.clip(t, 0, 1)) + + a_val = int(np.clip(255.0 * mdn_alpha[0], 5, 255)) + a = np.full(len(base_rgb), a_val, np.uint8) + if vis_mask is not None: + a = a.copy() + a[~vis_mask] = 0 + + rgba = np.concatenate([base_rgb, a[:, None]], axis=1).astype(np.uint8) + color_arr.DeepCopy(numpy_to_vtk(rgba, deep=True)) + color_arr.Modified() + poly.Modified() + last_rgba[0] = rgba + + # Warm-up + for _ in range(12): + V = sampler_mean.sample_vec(P) + P = wrap_inside(P + V * (args.dt * args.speed_scale) * (target_step / max(vmax_mean, 1e-9)), + amin, amax, margin_frac=args.margin) + if ENT is not None: + ent_prev = sampler_mean.sample_scalar(ENT, P).astype(np.float32) + ent_d_ema[:] = 0.0 + + # ---------- timer callback ---------- + def on_timer(_o, _e): + nonlocal P, ages, ttl, ent_prev, ent_d_ema + + # Check for async GPT result + if gpt_pending["result"] is not None: + explanation = gpt_pending["result"] + gpt_pending["result"] = None + gpt_pending["running"] = False + print("\n--- GPT INTERPRETATION ---") + print(explanation) + print("--- END ---\n") + text_overlay.show_gpt(explanation) + text_overlay.add_log("GPT analysis complete") + + if state["paused"]: + return + + samp = current_sampler() + Vraw = samp.sample_vec(P) + + vmx = float(vmax_for_mode()) + if clip_state["on"]: + thr = float(max(1e-9, clip_state["frac"] * vmx)) + s = np.linalg.norm(Vraw, axis=1) + k = np.minimum(1.0, thr / (s + 1e-9)).astype(np.float32) + Vstep = Vraw * k[:, None] + eff_vmax = min(vmx, thr) + else: + Vstep = Vraw + eff_vmax = vmx + + step = state["dt"] * state["scale"] * (target_step / max(eff_vmax, 1e-9)) + P = wrap_inside(P + Vstep * step, amin, amax, margin_frac=args.margin) + + dent = None + if ENT is not None: + ent_now = sampler_mean.sample_scalar(ENT, P).astype(np.float32) + d = ent_now - ent_prev + ent_d_ema = (1.0 - ent_alpha) * ent_d_ema + ent_alpha * d + ent_prev = ent_now + dent = ent_d_ema + + age_inc = np.ones(len(P), np.int32) + if sampler_oosdist is not None and OOS_DIST is not None and far_oos_params is not None: + d_oos = sampler_oosdist.sample_scalar(OOS_DIST, P).astype(np.float32) + d0 = max(1e-9, far_oos_params["d0"]) + t = np.clip((d_oos - d0) / d0, 0.0, 1.0) + gate = np.power(t, float(max(0.1, far_oos_params["gamma"]))).astype(np.float32) + extra = np.floor(gate * float(max(0.0, far_oos_params["boost"])) + 1e-9).astype(np.int32) + age_inc += extra + ages += age_inc + + dead = ages >= ttl + if np.any(dead): + if seed_mode == "overlap" and seed_points is not None and len(seed_points) > 0: + sel = rng.integers(0, len(seed_points), size=dead.sum()) + base = seed_points[sel] + if overlap_sigma > 0: + J = rng.standard_normal((dead.sum(), 3)).astype(np.float32) * overlap_sigma + P[dead] = np.clip(base + J, amin, amax) + else: + P[dead] = np.clip(base, amin, amax) + else: + jitter_world = (amax - amin) * (args.respawn_jitter / max((n_axis or 2) - 1, 1)) + J = (rng.random((dead.sum(), 3)).astype(np.float32) - 0.5) * 2.0 * jitter_world + P[dead] = np.clip(P0[dead] + J, amin, amax) + ages[dead] = 0 + ttl[dead] = _sample_ttl(dead.sum()) + if ENT is not None: + ent_prev[dead] = sampler_mean.sample_scalar(ENT, P[dead]).astype(np.float32) + ent_d_ema[dead] = 0.0 + + # Probe step + if probe_sys.active: + probe_sys.step(samp, step) + + # Render + speed = np.linalg.norm(Vstep, axis=1) + if filter_state["mode"] == "OFF": + vis_mask = np.ones(len(P), dtype=bool) + else: + vis_mask = compute_fraction_mask(speed, filter_state["frac"], + top=filter_state["dir_top"]) + + points.SetData(numpy_to_vtk(P, deep=True)) + points.Modified() + apply_colors(speed, P, dent_vals=dent, vis_mask=vis_mask) + win.Render() + + # ---------- click handler ---------- + def on_left_click(obj, ev): + if not probe_mode[0]: + return + x, y = obj.GetEventPosition() + if picker.Pick(x, y, 0, ren) <= 0: + return + px, py, pz = picker.GetPickPosition() + if not np.isfinite([px, py, pz]).all(): + return + pos = np.array([px, py, pz], np.float32) + probe_sys.place(pos) + probe_mode[0] = False + n = len(probe_sys.probes) + print(f"[probe] {n} probe(s) placed. Shift+G to analyze, C to clear.") + + # ---------- async GPT ---------- + def _run_gpt_full_async(probe_snapshots, use_rag_flag): + """Run FULL analysis + GPT in background thread (no VTK calls).""" + try: + all_transitions = [] + branch_transitions = [] + for snap in probe_snapshots: + path_arr = snap["path"] + field_mags = snap["field_mags"] + transitions = analyze_probe_path( + path_arr, flow_mesh, sample_every=5, + field_mags=field_mags if len(field_mags) == len(path_arr) else None, + entropy_sampler=sampler_mean if ENT is not None else None, + entropy_field=ENT, + ) + if snap["ghost"]: + branch_transitions.extend(transitions) + else: + all_transitions.extend(transitions) + + if not all_transitions: + gpt_pending["result"] = "No brain regions detected along probe path." + return + + # Log transitions (console only, no VTK) + print("\n--- PROBE TRAJECTORY ANALYSIS ---") + for i, t in enumerate(all_transitions, 1): + print(f" {i}. {t['region_name']} ({t['relative_position']}), " + f"steps {t['entry_idx']}-{t['exit_idx']}") + if branch_transitions: + print(f" + {len(branch_transitions)} branch transitions") + + rag_label = "RAG" if use_rag_flag else "direct" + print(f"\n[GPT] Sending to GPT ({rag_label})...") + explanation = analyze_with_gpt(all_transitions, use_rag=use_rag_flag, + model=model, debug=debug_mode) + gpt_pending["result"] = explanation + except Exception as e: + gpt_pending["result"] = f"[GPT ERROR] {e}" + + # ---------- key handler ---------- + def on_keypress(obj, ev): + nonlocal ttl, ages + key = obj.GetKeySym() + key_lower = key.lower() if key else "" + shift = bool(obj.GetShiftKey()) + + if key_lower == "escape": + obj.TerminateApp() + + elif key_lower == "space": + state["paused"] = not state["paused"] + print(f"[{'PAUSED' if state['paused'] else 'RUNNING'}]") + + elif key_lower == "f": + i = field_modes.index(field_mode[0]) + field_mode[0] = field_modes[(i + 1) % len(field_modes)] + print(f"[field] {field_mode[0]}") + + elif key_lower == "v": + if available_colour_modes: + colour_idx[0] = (colour_idx[0] + 1) % len(available_colour_modes) + print(f"[colour] {available_colour_modes[colour_idx[0]]}") + + elif key_lower == "m": + cmap_mode[0] = "RAINBOW" if cmap_mode[0] == "TURBO" else "TURBO" + print(f"[cmap] {cmap_mode[0]}") + + # Flow mesh + elif key_lower == "q" and flow_mesh: + flow_mesh.cycle(-1) + elif key_lower == "w" and flow_mesh: + flow_mesh.cycle(+1) + elif key_lower == "backslash" and flow_mesh: + flow_mesh.hide() + elif key_lower == "9" and flow_mesh: + flow_mesh.set_opacity(1.0 / 1.25) + elif key_lower == "0" and flow_mesh: + flow_mesh.set_opacity(1.25) + + # Sim controls + elif key_lower == "bracketleft": + state["dt"] /= 1.25 + print(f"[dt] {state['dt']:.4f}") + elif key_lower == "bracketright": + state["dt"] *= 1.25 + print(f"[dt] {state['dt']:.4f}") + elif key_lower in ("plus", "equal"): + state["scale"] *= 1.25 + print(f"[speed-scale] {state['scale']:.3f}") + elif key_lower in ("minus", "underscore"): + state["scale"] /= 1.25 + print(f"[speed-scale] {state['scale']:.3f}") + + elif key_lower == "1": + ttl_state["scale"] = float(np.clip(ttl_state["scale"] / 1.25, 0.1, 100.0)) + ttl[:] = np.maximum(1, np.round(ttl * (1.0 / 1.25))).astype(np.int32) + print(f"[lifetime] scale = {ttl_state['scale']:.3f}") + elif key_lower == "2": + ttl_state["scale"] = float(np.clip(ttl_state["scale"] * 1.25, 0.1, 100.0)) + ttl[:] = np.maximum(1, np.round(ttl * 1.25)).astype(np.int32) + print(f"[lifetime] scale = {ttl_state['scale']:.3f}") + + elif key_lower == "7": + mdn_alpha[0] = float(np.clip(mdn_alpha[0] / 1.25, 0.02, 1.0)) + print(f"[alpha] {mdn_alpha[0]:.2f}") + elif key_lower == "8": + mdn_alpha[0] = float(np.clip(mdn_alpha[0] * 1.25, 0.02, 1.0)) + print(f"[alpha] {mdn_alpha[0]:.2f}") + + elif key_lower == "y": + filter_state["mode"] = "OFF" if filter_state["mode"] == "SPEED" else "SPEED" + print(f"[filter] mode={filter_state['mode']}") + elif key_lower == "u": + filter_state["dir_top"] = not filter_state["dir_top"] + print(f"[filter] dir={'TOP' if filter_state['dir_top'] else 'BOTTOM'}") + elif key_lower == "z": + filter_state["frac"] = float(np.clip(filter_state["frac"] / 1.25, 0.0, 1.0)) + print(f"[filter] frac={filter_state['frac'] * 100:.1f}%") + elif key_lower == "x": + filter_state["frac"] = float(np.clip(filter_state["frac"] * 1.25, 0.0, 1.0)) + print(f"[filter] frac={filter_state['frac'] * 100:.1f}%") + + elif key_lower == "j": + clip_state["on"] = not clip_state["on"] + print(f"[speed-clip] {'ON' if clip_state['on'] else 'OFF'}") + elif key_lower == "a": + clip_state["frac"] = float(np.clip(clip_state["frac"] / 1.25, 0.02, 10.0)) + print(f"[speed-clip] frac={clip_state['frac']:.3f}") + elif key_lower == "d": + clip_state["frac"] = float(np.clip(clip_state["frac"] * 1.25, 0.02, 10.0)) + print(f"[speed-clip] frac={clip_state['frac']:.3f}") + + elif key_lower == "o": + if o_actor is not None: + if oos_visible[0]: + ren.RemoveActor(o_actor) + oos_visible[0] = False + else: + ren.AddActor(o_actor) + oos_visible[0] = True + print(f"[oos overlay] visible = {oos_visible[0]}") + win.Render() + + # ---------- probe controls ---------- + elif key_lower == "g" and not shift: + probe_mode[0] = True + print("[probe] Click in the scene to place probe...") + + elif key_lower == "g" and shift: + if not probe_sys.active: + print("[probe] No probe active. Press g then click.") + text_overlay.add_log("No probe to analyze") + elif flow_mesh is None: + print("[probe] Flow mesh required.") + elif gpt_pending["running"]: + print("[probe] GPT analysis already running...") + else: + # Stop probe movement — we're analyzing the path so far + probe_sys.freeze() + print("[probe] Probe stopped. Analyzing trajectory...") + text_overlay.add_log("Please wait — analyzing probe path...") + win.Render() + + # Collect probe data snapshots (lightweight, no VTK calls) + probe_snapshots = [] + for p in probe_sys.get_all_probes(): + if not p.path or len(p.path) < 3: + continue + probe_snapshots.append({ + "path": p.get_path_array().copy(), + "field_mags": p.get_field_mags_array().copy(), + "ghost": p.ghost, + "label": p.label, + }) + + if not probe_snapshots: + print("[probe] No probe data.") + text_overlay.add_log("No probe data") + else: + gpt_pending["running"] = True + t = threading.Thread( + target=_run_gpt_full_async, + args=(probe_snapshots, use_rag), + daemon=True + ) + t.start() + + elif key_lower == "c": + probe_sys.clear() + probe_mode[0] = False + text_overlay.clear_log() + text_overlay.hide_gpt() + + # ---------- branching / multi-probe ---------- + elif key_lower == "b": + new_state = not probe_sys._branching_enabled + if PI is not None: + probe_sys.set_branching(new_state, pi_field=PI, mus_samplers=samplers_comp) + text_overlay.add_log(f"Branching {'ON' if new_state else 'OFF'}") + else: + print("[probe] No PI field - branching not available") + + elif key_lower == "n": + counts = [1, 4, 8] + cur = probe_sys._multi_count + idx = counts.index(cur) if cur in counts else 0 + new_count = counts[(idx + 1) % len(counts)] + probe_sys.set_multi_count(new_count) + text_overlay.add_log(f"Multi-probe: {new_count}") + + # ---------- brain state ---------- + elif key_lower == "s" and not shift: + # Initialize brain states + if flow_mesh is None: + print("[brain-state] Flow mesh required.") + return + region_names = [] + for k in flow_mesh.get_all_region_keys(): + name = flow_mesh.get_region_name(k) + if hasattr(flow_mesh, '_hemispheres') and k in flow_mesh._hemispheres: + region_names.append(f"{name} (left)") + region_names.append(f"{name} (right)") + else: + region_names.append(name) + print("[brain-state] Enter global brain state (or press Enter for default):") + print(" Example: 'someone thinking about loved ones'") + + def _init_states_async(): + try: + # Read from stdin (blocking) + import sys + global_state = input(" > ").strip() + result = brain_state_db.initialize_from_global( + global_state, region_names, + callback=lambda msg: print(f" {msg}") + ) + print(f"[brain-state] {result}") + text_overlay.add_log("Brain states initialized") + except Exception as e: + print(f"[brain-state] Error: {e}") + + t = threading.Thread(target=_init_states_async, daemon=True) + t.start() + + elif key_lower == "s" and shift: + # Propagate state through probe path + if not probe_sys.active or flow_mesh is None: + print("[brain-state] Need active probe + flow mesh.") + return + if not brain_state_db.has_states(): + print("[brain-state] Initialize states first (press 's').") + return + + # Snapshot probe data (lightweight) + primary = probe_sys.get_primary_probe() + if primary is None or len(primary.path) < 3: + print("[brain-state] No probe path.") + return + prop_snapshot = { + "path": primary.get_path_array().copy(), + "field_mags": primary.get_field_mags_array().copy(), + } + + def _propagate_async(): + try: + path_arr = prop_snapshot["path"] + field_mags = prop_snapshot["field_mags"] + transitions = analyze_probe_path( + path_arr, flow_mesh, sample_every=5, + field_mags=field_mags if len(field_mags) == len(path_arr) else None, + ) + if not transitions: + print("[brain-state] No regions in path.") + return + + def _hemi_name(t): + name = t["region_name"] + h = t.get("hemisphere") + return f"{name} ({h})" if h else name + + source = _hemi_name(transitions[0]) + affected = [_hemi_name(t) for t in transitions[1:]] + strengths = {_hemi_name(t): t.get("avg_flow_strength", 0.0) or 0.0 + for t in transitions} + + print(f"[brain-state] Propagating from {source}...") + updates = brain_state_db.propagate_through_regions( + source, affected, flow_strengths=strengths, + callback=lambda m: print(f" {m}") + ) + if updates: + summary = brain_state_db.summarize_changes(updates, source) + story = brain_state_db.generate_flow_story(updates, source) + print(f"\n--- PROPAGATION SUMMARY ---") + print(summary) + print(f"\n--- INFORMATION FLOW STORY ---") + print(story) + print("--- END ---\n") + gpt_pending["result"] = f"{summary}\n\n{story}" + else: + print("[brain-state] No state changes.") + except Exception as e: + print(f"[brain-state] Error: {e}") + + t = threading.Thread(target=_propagate_async, daemon=True) + t.start() + + # ---------- legend ---------- + add_window_legend(ren, [ + "mindVisualizer", + "space pause | f field | v colour | m cmap | q/w mesh | \\ hide", + "[ ] dt | +/- speed | 1/2 lifetime | 7/8 alpha | y/u/z/x filter", + "o OOS | j clip | a/d frac | g probe | G(shift) analyze(async)", + "b branching | n multi(1/4/8) | s states | S propagate", + "c clear | Esc quit", + ], font_px=12) + + # ---------- interactor ---------- + iren = vtk.vtkRenderWindowInteractor() + iren.SetRenderWindow(win) + iren.SetInteractorStyle(vtkInteractorStyleTrackballCamera()) + iren.Initialize() + iren.AddObserver("TimerEvent", on_timer) + iren.AddObserver("KeyPressEvent", on_keypress) + iren.AddObserver("LeftButtonPressEvent", on_left_click) + iren.CreateRepeatingTimer(max(1, int(1000 / max(1, args.fps)))) + win.Render() + + rag_status = "RAG" if use_rag else "direct (no RAG)" + print("\n=== mindVisualizer ready ===") + print(f" Particles: {Np} | Seed: {seed_mode} | Field: {field_mode[0]}") + print(f" Colours: {', '.join(available_colour_modes)}") + print(f" Meshes: {len(flow_mesh.get_all_region_keys()) if flow_mesh else 0} regions") + print(f" GPT: {rag_status} | Multi-probe: {probe_sys._multi_count} | " + f"Branching: {'ON' if probe_sys._branching_enabled else 'OFF'}") + print(f" Brain states: {'loaded' if brain_state_db.has_states() else 'not initialized (press s)'}") + print(f" Press 'g' then click to place probe. Shift+G to analyze.") + print() + iren.Start() + + +if __name__ == "__main__": + try: + main() + except Exception as e: + print("[ERROR]", e) + import traceback + traceback.print_exc() + sys.exit(1) diff --git a/src/mesh_overlay.py b/src/mesh_overlay.py new file mode 100644 index 0000000000000000000000000000000000000000..6328f181eed7532a2f54e67c2260648004569cc3 --- /dev/null +++ b/src/mesh_overlay.py @@ -0,0 +1,821 @@ +"""Brain-region mesh overlay for VTK renderers. + +Loads OBJ meshes from data/meshes_obj/, applies um->mm scaling + alignment matrix, +and provides cycling/toggling/opacity controls. Loads anatomical names from Allen +Human Brain Atlas structures.json. + +Hemisphere detection: symmetric meshes (two connected components split along X) +are stored as "{key}_left" and "{key}_right" for per-hemisphere queries. + +Region lookup: a 3D voxel label grid is pre-computed at startup so that +point-in-region queries are O(1) array lookups — fast and reliable regardless +of mesh quality (holes, non-watertight surfaces, etc.). +""" + +import json +from pathlib import Path + +import numpy as np +import vtk + +DEFAULT_MESH_SCALE = 0.001 # um -> mm +LABEL_GRID_RESOLUTION = 100 # voxels per axis for region lookup grid + + +# --------------------------------------------------------------------------- +# helpers +# --------------------------------------------------------------------------- + +def _load_alignment(path: Path): + """Load 4x4 alignment matrix from JSON.""" + try: + M = json.loads(Path(path).read_text())["matrix"] + mx = vtk.vtkMatrix4x4() + for i in range(4): + for j in range(4): + mx.SetElement(i, j, float(M[i][j])) + return mx + except Exception: + print("[mesh] no/invalid alignment json; showing raw OBJ coords") + return None + + +def _load_structures(data_dir: Path) -> dict[str, str]: + """Load region ID -> name mapping from Allen atlas structures.json.""" + mapping: dict[str, str] = {} + structs_path = data_dir / "structures.json" + if structs_path.exists(): + try: + structs = json.loads(structs_path.read_text(encoding="utf-8")) + for s in structs: + sid = str(s["id"]) + name = s.get("name", sid) + acronym = s.get("acronym", "") + if acronym and acronym != "root": + mapping[sid] = f"{name} ({acronym})" + else: + mapping[sid] = name + except Exception as e: + print(f"[mesh] warning: could not parse structures.json: {e}") + mapping.setdefault("brain_outline", "Whole Brain Outline") + return mapping + + +def _polydata_center_x(poly: vtk.vtkPolyData) -> float: + """Return the X coordinate of the center of mass of *poly*.""" + com = vtk.vtkCenterOfMass() + com.SetInputData(poly) + com.SetUseScalarsAsWeights(False) + com.Update() + return com.GetCenter()[0] + + +def _polydata_bounds(poly: vtk.vtkPolyData) -> tuple[float, ...]: + """Return (xmin, xmax, ymin, ymax, zmin, zmax).""" + return poly.GetBounds() + + +# --------------------------------------------------------------------------- +# main class +# --------------------------------------------------------------------------- + +class FlowMeshOverlay: + """Brain-region mesh overlay for the main flow renderer. + + Loads OBJ meshes from ``mesh_dir/../meshes_obj``, applies alignment + matrix (um->mm scale + JSON alignment), and optionally splits symmetric + meshes into left/right hemispheres. + """ + + def __init__(self, ren: vtk.vtkRenderer, win: vtk.vtkRenderWindow, + mesh_dir: Path, alignment_file: Path, + data_dir: Path | None = None): + self.ren = ren + self.win = win + self.dir = Path(mesh_dir) + self._actors: dict[str, vtk.vtkActor] = {} + self._polydata: dict[str, vtk.vtkPolyData] = {} + self._visible: str | None = None + self._opacity = 0.35 + self._align = _load_alignment(alignment_file) + + # Pre-computed bounding boxes: key -> (xmin, xmax, ymin, ymax, zmin, zmax) + self._bboxes: dict[str, tuple[float, ...]] = {} + + # Hemisphere splits: key -> {"left": vtkPolyData, "right": vtkPolyData} + self._hemispheres: dict[str, dict[str, vtk.vtkPolyData]] = {} + + # Load region names from atlas + if data_dir is None: + data_dir = self.dir.parent + self._names = _load_structures(data_dir) + + # ----- discover OBJ meshes only ----- + obj_dir = self.dir.parent / "meshes_obj" + if not obj_dir.exists(): + obj_dir = self.dir # fall back to meshes/ if meshes_obj/ absent + self.keys: dict[str, Path] = {} + if obj_dir.exists(): + for obj in sorted(obj_dir.glob("*.obj")): + self.keys[obj.stem] = obj + else: + print(f"[mesh] warning: OBJ directory not found: {obj_dir}") + + # Skip overly broad structural IDs + _skip = {"10153", "10154", "10155", "10156", "10157", "10158", + "10557", "10668"} + self._order = [k for k in self.keys + if k != "brain_outline" and k not in _skip] + + # Compute bounding-box volumes and filter oversized meshes + self._bb_volumes: dict[str, float] = {} + self._compute_volumes_and_filter() + + # Optional extra parcellation (set via set_extra_parcellation) + self._extra = None + + print(f"[mesh] discovered {len(self._order)} region meshes " + f"({len(self.keys)} OBJ)") + + # ------------------------------------------------------------------ + # extra parcellation + # ------------------------------------------------------------------ + + def set_extra_parcellation(self, extra): + """Attach an optional ExtraParcellation for finer subregion queries. + + Args: + extra: ExtraParcellation instance (already loaded) + """ + self._extra = extra + + def get_hierarchical_regions_at_point(self, point: np.ndarray) -> dict: + """Return both primary (Allen) and subregion (extra parcellation) at a point. + + Returns: + { + "primary": str | None, # Allen atlas region key + "primary_name": str | None, # Allen atlas region name + "subregion": dict | None, # {"label_id", "name", "volume_mm3"} from extra + "subregion_mesh": vtkPolyData | None # mesh if available + } + """ + result = { + "primary": None, + "primary_name": None, + "subregion": None, + "subregion_mesh": None, + } + + # Primary lookup (Allen atlas) + primary_key = self.get_region_at_point(point) + if primary_key is None: + primary_key = self.find_nearest_region(point, search_radius=2, + max_distance_mm=5.0) + if primary_key: + result["primary"] = primary_key + result["primary_name"] = self.get_region_name(primary_key) + + # Extra parcellation lookup + if self._extra is not None and self._extra.is_loaded(): + sub = self._extra.get_region_at_point(point) + if sub is None: + sub = self._extra.get_nearby_region(point, search_radius=2) + if sub is not None: + result["subregion"] = sub + # Try to get mesh for highlighting + mesh = self._extra.get_region_mesh(sub["label_id"]) + if mesh is not None: + result["subregion_mesh"] = mesh + + return result + + # ------------------------------------------------------------------ + # volume filtering + # ------------------------------------------------------------------ + + def _compute_volumes_and_filter(self): + """Compute bounding-box volumes and exclude overly large meshes.""" + vols: dict[str, float] = {} + for key in list(self._order): + poly = self.get_polydata(key) + if poly is not None and poly.GetNumberOfPoints() > 0: + b = poly.GetBounds() + vol = (b[1] - b[0]) * (b[3] - b[2]) * (b[5] - b[4]) + vols[key] = vol + self._bb_volumes = vols + + if not vols: + return + + max_vol = max(vols.values()) + threshold = max_vol * 0.25 + oversized = [k for k, v in vols.items() if v > threshold] + if oversized: + names = [self.get_region_name(k) for k in oversized] + print(f"[mesh] excluding {len(oversized)} oversized regions: " + f"{', '.join(names[:5])}{'...' if len(names) > 5 else ''}") + self._order = [k for k in self._order if k not in oversized] + + # ------------------------------------------------------------------ + # voxel label grid (pre-computed for fast point-in-region queries) + # ------------------------------------------------------------------ + + def build_label_grid(self): + """Pre-compute a 3D voxel grid mapping each voxel to a region key. + + After this call, point_in_region() / get_region_at_point() use fast + O(1) array lookups instead of expensive vtkSelectEnclosedPoints. + """ + # Determine brain bounds from brain outline or all meshes + brain_poly = self.get_polydata("10155") # brain outline + if brain_poly is not None: + bb = brain_poly.GetBounds() + else: + # Fallback: union of all mesh bounding boxes + all_bounds = [self.get_polydata(k).GetBounds() + for k in self._order if self.get_polydata(k) is not None] + if not all_bounds: + print("[mesh] no meshes loaded, cannot build label grid") + return + bb = (min(b[0] for b in all_bounds), max(b[1] for b in all_bounds), + min(b[2] for b in all_bounds), max(b[3] for b in all_bounds), + min(b[4] for b in all_bounds), max(b[5] for b in all_bounds)) + + margin = 2.0 + self._grid_origin = np.array([bb[0] - margin, bb[2] - margin, + bb[4] - margin], dtype=np.float32) + self._grid_max = np.array([bb[1] + margin, bb[3] + margin, + bb[5] + margin], dtype=np.float32) + N = LABEL_GRID_RESOLUTION + self._grid_spacing = (self._grid_max - self._grid_origin) / N + self._grid_N = N + + # key_index: 0 = no region, 1..len = region keys + self._grid_keys = [""] # index 0 = empty + key_to_idx = {} + for ki, key in enumerate(self._order): + self._grid_keys.append(key) + key_to_idx[key] = ki + 1 + + # Initialize grid to 0 (no region) + self._label_grid = np.zeros((N, N, N), dtype=np.int16) + + # Build all voxel centers + print(f"[mesh] building {N}^3 label grid for {len(self._order)} regions...") + total = N * N * N + all_pts = vtk.vtkPoints() + all_pts.SetNumberOfPoints(total) + idx = 0 + for iz in range(N): + z = self._grid_origin[2] + (iz + 0.5) * self._grid_spacing[2] + for iy in range(N): + y = self._grid_origin[1] + (iy + 0.5) * self._grid_spacing[1] + for ix in range(N): + x = self._grid_origin[0] + (ix + 0.5) * self._grid_spacing[0] + all_pts.SetPoint(idx, x, y, z) + idx += 1 + + all_pd = vtk.vtkPolyData() + all_pd.SetPoints(all_pts) + + # For each mesh, batch test all voxels + # Process smallest meshes first (they are most specific / finest granularity) + ordered_keys = sorted(self._order, + key=lambda k: self._bb_volumes.get(k, 0)) + + for mi, key in enumerate(ordered_keys): + poly = self.get_polydata(key) + if poly is None or poly.GetNumberOfCells() < 10: + continue + + mesh_bb = poly.GetBounds() + + # Index range that overlaps this mesh bbox + ix_lo = max(0, int((mesh_bb[0] - self._grid_origin[0]) / + self._grid_spacing[0] - 1)) + ix_hi = min(N, int((mesh_bb[1] - self._grid_origin[0]) / + self._grid_spacing[0] + 2)) + iy_lo = max(0, int((mesh_bb[2] - self._grid_origin[1]) / + self._grid_spacing[1] - 1)) + iy_hi = min(N, int((mesh_bb[3] - self._grid_origin[1]) / + self._grid_spacing[1] + 2)) + iz_lo = max(0, int((mesh_bb[4] - self._grid_origin[2]) / + self._grid_spacing[2] - 1)) + iz_hi = min(N, int((mesh_bb[5] - self._grid_origin[2]) / + self._grid_spacing[2] + 2)) + + # Collect candidate flat indices + candidates = [] + for iz in range(iz_lo, iz_hi): + for iy in range(iy_lo, iy_hi): + for ix in range(ix_lo, ix_hi): + candidates.append((ix, iy, iz, + iz * N * N + iy * N + ix)) + + if not candidates: + continue + + # Build test polydata with only candidate points + test_pts = vtk.vtkPoints() + test_pts.SetNumberOfPoints(len(candidates)) + for ci, (_, _, _, flat) in enumerate(candidates): + test_pts.SetPoint(ci, all_pts.GetPoint(flat)) + test_pd = vtk.vtkPolyData() + test_pd.SetPoints(test_pts) + + enc = vtk.vtkSelectEnclosedPoints() + enc.SetSurfaceData(poly) + enc.SetTolerance(0.001) + enc.SetInputData(test_pd) + enc.Update() + + region_idx = key_to_idx[key] + count = 0 + for ci, (ix, iy, iz, _) in enumerate(candidates): + if enc.IsInside(ci): + self._label_grid[ix, iy, iz] = region_idx + count += 1 + + enc.Complete() + + if (mi + 1) % 20 == 0 or count > 0: + name = self.get_region_name(key) + filled = int(np.count_nonzero(self._label_grid)) + print(f" [{mi+1}/{len(ordered_keys)}] {name}: " + f"{count} voxels, total filled: {filled}") + + total_filled = int(np.count_nonzero(self._label_grid)) + print(f"[mesh] label grid complete: {total_filled}/{total} voxels " + f"filled ({total_filled/total*100:.1f}%)") + self._label_grid_ready = True + + def save_label_grid(self, path: Path): + """Save the pre-computed label grid to disk for fast startup.""" + if not getattr(self, '_label_grid_ready', False): + print("[mesh] no label grid to save") + return + path = Path(path) + data = { + "grid": self._label_grid, + "origin": self._grid_origin, + "max": self._grid_max, + "spacing": self._grid_spacing, + "N": self._grid_N, + "keys": self._grid_keys, + } + np.savez_compressed(str(path), **{ + "grid": self._label_grid, + "origin": self._grid_origin, + "grid_max": self._grid_max, + "spacing": self._grid_spacing, + }) + # Save keys separately as JSON (string list) + keys_path = path.with_suffix('.keys.json') + keys_path.write_text(json.dumps(self._grid_keys), encoding='utf-8') + print(f"[mesh] saved label grid to {path}") + + def load_label_grid(self, path: Path) -> bool: + """Load a pre-computed label grid from disk. + + Returns True if loaded successfully, False otherwise. + """ + path = Path(path) + keys_path = path.with_suffix('.keys.json') + if not path.exists() or not keys_path.exists(): + return False + try: + d = np.load(str(path)) + self._label_grid = d["grid"] + self._grid_origin = d["origin"].astype(np.float32) + self._grid_max = d["grid_max"].astype(np.float32) + self._grid_spacing = d["spacing"].astype(np.float32) + self._grid_N = int(self._label_grid.shape[0]) + self._grid_keys = json.loads(keys_path.read_text(encoding='utf-8')) + + # Validate keys match loaded meshes + loaded_mesh_keys = set(self._order) + grid_keys_set = set(self._grid_keys[1:]) # skip index 0 (empty) + if not grid_keys_set.issubset(loaded_mesh_keys): + missing = grid_keys_set - loaded_mesh_keys + print(f"[mesh] cached grid has {len(missing)} keys not in current meshes, rebuilding...") + return False + + total_filled = int(np.count_nonzero(self._label_grid)) + total = self._grid_N ** 3 + print(f"[mesh] loaded cached label grid: {total_filled}/{total} voxels " + f"filled ({total_filled/total*100:.1f}%)") + self._label_grid_ready = True + return True + except Exception as e: + print(f"[mesh] failed to load cached grid: {e}") + return False + + def get_region_at_point(self, point: np.ndarray) -> str | None: + """Return the region key at a 3D point using the label grid. + + Returns None if no region, or the key string (e.g. "12114"). + """ + if not getattr(self, '_label_grid_ready', False): + return None + p = np.asarray(point, dtype=np.float32).ravel()[:3] + idx = ((p - self._grid_origin) / self._grid_spacing).astype(int) + N = self._grid_N + if np.any(idx < 0) or np.any(idx >= N): + return None + val = self._label_grid[idx[0], idx[1], idx[2]] + if val == 0: + return None + return self._grid_keys[val] + + def get_all_regions_at_point(self, point: np.ndarray) -> list[str]: + """Return all region keys near a 3D point. + + Checks the point's voxel and its 26 neighbors to catch points near + region boundaries. + """ + if not getattr(self, '_label_grid_ready', False): + return [] + p = np.asarray(point, dtype=np.float32).ravel()[:3] + idx = ((p - self._grid_origin) / self._grid_spacing).astype(int) + N = self._grid_N + if np.any(idx < 0) or np.any(idx >= N): + return [] + + found = set() + # Check center voxel first + val = self._label_grid[idx[0], idx[1], idx[2]] + if val > 0: + found.add(self._grid_keys[val]) + + return list(found) + + def find_nearest_region(self, point: np.ndarray, + search_radius: int = 3, + max_distance_mm: float = 0.0) -> str | None: + """Find nearest region within search_radius voxels of point. + + Useful when a point falls between regions (in a tiny gap). + + Args: + point: 3D position in mm. + search_radius: max voxels to search outward. + max_distance_mm: if > 0, reject matches farther than this (in mm). + Distance is measured to the nearest labelled voxel + center, which approximates distance to surface for + the grid resolution we use. + """ + if not getattr(self, '_label_grid_ready', False): + return None + p = np.asarray(point, dtype=np.float32).ravel()[:3] + idx = ((p - self._grid_origin) / self._grid_spacing).astype(int) + N = self._grid_N + + # Spiral outward from center + for r in range(0, search_radius + 1): + for dz in range(-r, r + 1): + for dy in range(-r, r + 1): + for dx in range(-r, r + 1): + if max(abs(dx), abs(dy), abs(dz)) != r: + continue # only check shell at distance r + ix = idx[0] + dx + iy = idx[1] + dy + iz = idx[2] + dz + if 0 <= ix < N and 0 <= iy < N and 0 <= iz < N: + val = self._label_grid[ix, iy, iz] + if val > 0: + if max_distance_mm > 0: + voxel_center = self._grid_origin + \ + (np.array([ix, iy, iz]) + 0.5) * self._grid_spacing + dist = float(np.linalg.norm(p - voxel_center)) + if dist > max_distance_mm: + continue + return self._grid_keys[val] + return None + + # ------------------------------------------------------------------ + # mesh loading + # ------------------------------------------------------------------ + + def _load_mesh(self, filepath: Path) -> vtk.vtkPolyData | None: + """Load an OBJ mesh file.""" + if filepath.suffix.lower() != ".obj": + return None + reader = vtk.vtkOBJReader() + reader.SetFileName(str(filepath)) + reader.Update() + return reader.GetOutput() + + def _transform_polydata(self, poly: vtk.vtkPolyData) -> vtk.vtkPolyData: + """Apply um->mm scaling and alignment matrix to *poly*.""" + tf_scale = vtk.vtkTransform() + tf_scale.Scale(DEFAULT_MESH_SCALE, DEFAULT_MESH_SCALE, DEFAULT_MESH_SCALE) + f1 = vtk.vtkTransformPolyDataFilter() + f1.SetInputData(poly) + f1.SetTransform(tf_scale) + f1.Update() + out = f1.GetOutput() + + if self._align is not None: + tf = vtk.vtkTransform() + tf.SetMatrix(self._align) + f2 = vtk.vtkTransformPolyDataFilter() + f2.SetInputData(out) + f2.SetTransform(tf) + f2.Update() + out = f2.GetOutput() + + return out + + # ------------------------------------------------------------------ + # hemisphere detection + # ------------------------------------------------------------------ + + def _detect_hemispheres(self, key: str, poly: vtk.vtkPolyData): + """If *poly* has exactly two connected regions split along X, store + them as left / right hemispheres for *key*.""" + conn = vtk.vtkConnectivityFilter() + conn.SetInputData(poly) + conn.SetExtractionModeToAllRegions() + conn.ColorRegionsOn() + conn.Update() + + n_regions = conn.GetNumberOfExtractedRegions() + if n_regions != 2: + return + + # Extract each region separately + regions: list[vtk.vtkPolyData] = [] + for rid in range(2): + extract = vtk.vtkConnectivityFilter() + extract.SetInputData(poly) + extract.SetExtractionModeToSpecifiedRegions() + extract.AddSpecifiedRegion(rid) + extract.Update() + regions.append(extract.GetOutput()) + + cx0 = _polydata_center_x(regions[0]) + cx1 = _polydata_center_x(regions[1]) + + # Overall midpoint along X from the full mesh bounds + bounds = poly.GetBounds() + mid_x = (bounds[0] + bounds[1]) / 2.0 + + # Check that the two regions sit on opposite sides of the midpoint + if (cx0 < mid_x and cx1 > mid_x): + self._hemispheres[key] = {"left": regions[0], "right": regions[1]} + elif (cx1 < mid_x and cx0 > mid_x): + self._hemispheres[key] = {"left": regions[1], "right": regions[0]} + # else: not a clean left/right split -- skip + + # ------------------------------------------------------------------ + # actor creation + # ------------------------------------------------------------------ + + def _actor_for(self, key: str) -> vtk.vtkActor | None: + if key in self._actors: + return self._actors[key] + if key not in self.keys: + return None + filepath = self.keys[key] + if not filepath.exists(): + print(f"[flow mesh] missing file: {filepath}") + return None + + poly = self._load_mesh(filepath) + if poly is None or poly.GetNumberOfPoints() == 0: + return None + + out = self._transform_polydata(poly) + + # Cache bounding box + self._bboxes[key] = _polydata_bounds(out) + + # Hemisphere detection + self._detect_hemispheres(key, out) + + # Build actor + mapper = vtk.vtkPolyDataMapper() + mapper.SetInputData(out) + act = vtk.vtkActor() + act.SetMapper(mapper) + if key == "brain_outline": + act.GetProperty().SetColor(0.85, 0.85, 0.85) + act.GetProperty().SetOpacity(max(0.15, self._opacity * 0.7)) + else: + act.GetProperty().SetColor(0.92, 0.92, 0.92) + act.GetProperty().SetOpacity(self._opacity) + act.GetProperty().LightingOff() + + self._actors[key] = act + self._polydata[key] = out + return act + + # ------------------------------------------------------------------ + # public: polydata / region info + # ------------------------------------------------------------------ + + def get_polydata(self, key: str) -> vtk.vtkPolyData | None: + if key not in self._polydata: + self._actor_for(key) + return self._polydata.get(key) + + def get_region_name(self, key: str) -> str: + return self._names.get(key, f"Region {key}") + + def get_all_region_keys(self) -> list[str]: + return list(self._order) + + def get_visible_key(self) -> str | None: + return self._visible + + # ------------------------------------------------------------------ + # public: hemisphere queries + # ------------------------------------------------------------------ + + def get_hemisphere_polydata(self, key: str, + point: np.ndarray) -> vtk.vtkPolyData | None: + """Return the hemisphere polydata (left or right) that contains *point*. + + If the mesh was cleanly split into 2 connected components, returns the + matching half. Otherwise, if the mesh spans both hemispheres (wide X + extent), clips it to the probe's side using a plane at the midpoint. + """ + # Ensure mesh is loaded (triggers hemisphere detection) + full = self.get_polydata(key) + hemi = self._hemispheres.get(key) + if hemi is not None: + label = self._classify_hemisphere(key, point) + return hemi.get(label, full) + + # Fallback: clip by X-plane if mesh spans both hemispheres + if full is None: + return full + bounds = self._bboxes.get(key) + if bounds is None: + return full + x_extent = bounds[1] - bounds[0] + y_extent = bounds[3] - bounds[2] + # Only clip if X extent is large relative to Y (indicates bilateral mesh) + if x_extent > y_extent * 0.6: + mid_x = (bounds[0] + bounds[1]) / 2.0 + plane = vtk.vtkPlane() + plane.SetOrigin(mid_x, 0, 0) + # Keep the side where the probe is: + # vtkClipPolyData keeps points where implicit function >= 0 (default) + # Plane function: dot(point - origin, normal) + # If probe is at x < mid_x, we want to keep x < mid_x + # normal = (-1,0,0): function = -(x - mid_x) = mid_x - x, positive for x < mid_x => kept + if float(point[0]) < mid_x: + plane.SetNormal(-1, 0, 0) # keep x < mid (probe's side) + else: + plane.SetNormal(1, 0, 0) # keep x > mid (probe's side) + clipper = vtk.vtkClipPolyData() + clipper.SetInputData(full) + clipper.SetClipFunction(plane) + clipper.Update() + clipped = clipper.GetOutput() + if clipped.GetNumberOfCells() > 0: + return clipped + + return full + + def get_hemisphere_label(self, key: str, point: np.ndarray) -> str | None: + """Return ``"left"`` or ``"right"`` for *point* relative to *key*. + + Returns ``None`` if the mesh has no hemisphere split. + """ + self.get_polydata(key) # ensure loaded + if key not in self._hemispheres: + return None + return self._classify_hemisphere(key, point) + + def _classify_hemisphere(self, key: str, point: np.ndarray) -> str: + """Decide whether *point* falls in the left or right hemisphere of + *key* by comparing its X coordinate to the mesh midpoint.""" + bounds = self._bboxes.get(key) + if bounds is None: + bounds = _polydata_bounds(self._polydata[key]) + mid_x = (bounds[0] + bounds[1]) / 2.0 + # After alignment transform, determine which side is anatomical left/right + # by checking probe X relative to mesh midpoint + return "left" if float(point[0]) < mid_x else "right" + + # ------------------------------------------------------------------ + # public: point-in-mesh with fast rejection + # ------------------------------------------------------------------ + + def fast_point_in_mesh(self, point: np.ndarray, key: str) -> bool: + """Check if *point* is inside the mesh identified by *key*. + + Performs a cheap bounding-box test first; only runs + ``vtkSelectEnclosedPoints`` if the point falls within the box. + """ + poly = self.get_polydata(key) + if poly is None or poly.GetNumberOfCells() == 0: + return False + + # --- fast bounding-box rejection --- + bb = self._bboxes.get(key) + if bb is None: + bb = _polydata_bounds(poly) + self._bboxes[key] = bb + px, py, pz = float(point[0]), float(point[1]), float(point[2]) + if (px < bb[0] or px > bb[1] or + py < bb[2] or py > bb[3] or + pz < bb[4] or pz > bb[5]): + return False + + # --- expensive enclosed-point test --- + return self._enclosed_test(poly, point) + + def point_in_mesh(self, point: np.ndarray, key: str) -> bool: + """Check if a 3D point is inside a mesh (no fast rejection).""" + poly = self.get_polydata(key) + if poly is None or poly.GetNumberOfCells() == 0: + return False + return self._enclosed_test(poly, point) + + @staticmethod + def _enclosed_test(poly: vtk.vtkPolyData, point: np.ndarray) -> bool: + pts = vtk.vtkPoints() + pts.InsertNextPoint(float(point[0]), float(point[1]), float(point[2])) + test_pd = vtk.vtkPolyData() + test_pd.SetPoints(pts) + + enc = vtk.vtkSelectEnclosedPoints() + enc.SetInputData(test_pd) + enc.SetSurfaceData(poly) + enc.SetTolerance(0.0001) + enc.Update() + return bool(enc.IsInside(0)) + + # ------------------------------------------------------------------ + # public: display + # ------------------------------------------------------------------ + + def show(self, key: str): + if key == self._visible: + return + self.hide() + act = self._actor_for(key) + if act is not None: + self.ren.AddActor(act) + self._visible = key + name = self.get_region_name(key) + print(f"[flow mesh] showing: {name} ({key})") + if self.win is not None: + self.win.Render() + + def hide(self): + if self._visible and self._visible in self._actors: + try: + self.ren.RemoveActor(self._actors[self._visible]) + except Exception: + pass + self._visible = None + if self.win is not None: + self.win.Render() + + def cycle(self, step: int = +1): + if not self._order: + return + if self._visible not in self._order: + idx = 0 if step >= 0 else len(self._order) - 1 + else: + idx = (self._order.index(self._visible) + step) % len(self._order) + self.show(self._order[idx]) + + def set_opacity(self, mul: float): + self._opacity = float(np.clip(self._opacity * float(mul), 0.05, 1.0)) + for k, a in self._actors.items(): + if k == "brain_outline": + a.GetProperty().SetOpacity(max(0.1, self._opacity * 0.7)) + else: + a.GetProperty().SetOpacity(self._opacity) + if self.win is not None: + self.win.Render() + + # ------------------------------------------------------------------ + # public: geometry queries + # ------------------------------------------------------------------ + + def get_mesh_center(self, key: str) -> np.ndarray | None: + poly = self.get_polydata(key) + if poly is None: + return None + com = vtk.vtkCenterOfMass() + com.SetInputData(poly) + com.SetUseScalarsAsWeights(False) + com.Update() + return np.array(com.GetCenter(), dtype=np.float32) + + def get_mesh_bounds(self, key: str) -> np.ndarray | None: + poly = self.get_polydata(key) + if poly is None: + return None + return np.array(poly.GetBounds(), dtype=np.float32) + + def get_bb_volume(self, key: str) -> float: + """Return cached bounding-box volume (or inf if unknown).""" + return self._bb_volumes.get(key, float('inf')) diff --git a/src/probe.py b/src/probe.py new file mode 100644 index 0000000000000000000000000000000000000000..470f35efa115a38e084ce4ff430105335e8313e7 --- /dev/null +++ b/src/probe.py @@ -0,0 +1,800 @@ +"""Flow probe system: single probe, multi-probe, and branching support. + +The probe is advected by the same velocity field as the particles. Its trajectory +is recorded and can be analyzed for brain region transitions. + +Features: + - Single probe: click to place, follows mean flow + - Multi-probe: initialize N probes in a local neighborhood + - Branching: when MDN components are highly uncertain (50/50 split), spawn + a ghost probe that follows the dominant alternative component + - Live region highlighting in RED (ghost highlights more transparent) +""" + +import numpy as np +import vtk + + +class FlowProbe: + """A single probe that follows the flow field.""" + + def __init__(self, ren: vtk.vtkRenderer, amin: np.ndarray, amax: np.ndarray, + color=(0.0, 1.0, 0.3), opacity=0.9, ghost=False, label=""): + self.ren = ren + self.amin = amin.astype(np.float32) + self.amax = amax.astype(np.float32) + self.active = False + self.position = None + self.path: list[np.ndarray] = [] + self.speeds: list[float] = [] + self.raw_field_mags: list[float] = [] # field magnitude (independent of speed scale) + self._max_path_len = 50000 + self._mesh_overlay = None + self._highlighted_regions: set[str] = set() + self._highlight_actors: dict[str, vtk.vtkActor] = {} + self._check_interval = 30 + self._step_counter = 0 + self.current_regions: set[str] = set() + self._boundary_check = None + self._on_region_change = None + self.ghost = ghost + self.label = label + self._color = color + self.steps_alive = 0 + self._stuck_counter = 0 + self._stuck_threshold = 150 # steps with near-zero movement before warning + self._stuck_warned = False + self._stuck_eps = 1e-6 # minimum displacement per step + # Debounce: require N consecutive detections before entering, N misses before leaving + self._debounce_enter = 3 # consecutive checks to confirm entry + self._debounce_leave = 5 # consecutive misses to confirm exit + self._region_hit_count: dict[str, int] = {} # key -> consecutive hit count + self._region_miss_count: dict[str, int] = {} # key -> consecutive miss count + self._nearest_max_dist_mm = 5.0 # hard limit for nearest-region snapping (mm) + + diag = float(np.linalg.norm(amax - amin)) + marker_alpha = 0.4 if ghost else 1.0 + trail_alpha = 0.35 if ghost else 0.9 + + # Probe marker (sphere) + self._sphere = vtk.vtkSphereSource() + self._sphere.SetRadius(0.012 * diag if not ghost else 0.008 * diag) + self._sphere.SetThetaResolution(16) + self._sphere.SetPhiResolution(16) + mapper = vtk.vtkPolyDataMapper() + mapper.SetInputConnection(self._sphere.GetOutputPort()) + self.marker_actor = vtk.vtkActor() + self.marker_actor.SetMapper(mapper) + self.marker_actor.GetProperty().SetColor(*color) + self.marker_actor.GetProperty().SetOpacity(marker_alpha) + self.marker_actor.GetProperty().LightingOff() + self.marker_actor.VisibilityOff() + ren.AddActor(self.marker_actor) + + # Trail line + self._trail_points = vtk.vtkPoints() + self._trail_cells = vtk.vtkCellArray() + self._trail_pd = vtk.vtkPolyData() + self._trail_pd.SetPoints(self._trail_points) + self._trail_pd.SetLines(self._trail_cells) + trail_mapper = vtk.vtkPolyDataMapper() + trail_mapper.SetInputData(self._trail_pd) + self.trail_actor = vtk.vtkActor() + self.trail_actor.SetMapper(trail_mapper) + self.trail_actor.GetProperty().SetColor(*color) + self.trail_actor.GetProperty().SetLineWidth(3.0 if not ghost else 2.0) + self.trail_actor.GetProperty().SetOpacity(trail_alpha) + self.trail_actor.GetProperty().LightingOff() + self.trail_actor.VisibilityOff() + ren.AddActor(self.trail_actor) + + def set_mesh_overlay(self, mesh_overlay): + self._mesh_overlay = mesh_overlay + + def set_boundary_check(self, fn): + self._boundary_check = fn + + def set_on_region_change(self, fn): + self._on_region_change = fn + + def place(self, position: np.ndarray): + """Place probe at position and start recording.""" + self.position = np.array(position, dtype=np.float32).ravel()[:3] + + # Boundary validation + if self._boundary_check is not None and not self._boundary_check(self.position): + found = False + diag = float(np.linalg.norm(self.amax - self.amin)) + for scale in [0.01, 0.02, 0.05, 0.1, 0.2]: + for _ in range(50): + offset = np.random.randn(3).astype(np.float32) * scale * diag + candidate = np.clip(self.position + offset, self.amin, self.amax) + if self._boundary_check(candidate): + self.position = candidate + found = True + break + if found: + break + if not found: + self.position = (self.amin + self.amax) / 2.0 + print(f"[probe{self.label}] Could not find valid position, using domain center") + + self.path = [self.position.copy()] + self.speeds = [0.0] + self.raw_field_mags = [0.0] + self.active = True + self._step_counter = 0 + self.steps_alive = 0 + self._stuck_counter = 0 + self._stuck_warned = False + + # Reset trail + self._trail_points = vtk.vtkPoints() + self._trail_cells = vtk.vtkCellArray() + self._trail_points.InsertNextPoint(*self.position.tolist()) + self._trail_pd.SetPoints(self._trail_points) + self._trail_pd.SetLines(self._trail_cells) + self._trail_pd.Modified() + + self.marker_actor.VisibilityOn() + self.marker_actor.SetPosition(*self.position.tolist()) + self.trail_actor.VisibilityOn() + + self._update_region_highlights() + tag = " (ghost)" if self.ghost else "" + print(f"[probe{self.label}{tag}] placed at " + f"({self.position[0]:.2f}, {self.position[1]:.2f}, {self.position[2]:.2f})") + + def step(self, sampler, dt_step: float): + """Advect one step using sampler.""" + if not self.active or self.position is None: + return + V = sampler.sample_vec(self.position[None, :]) + velocity = V[0] + raw_mag = float(np.linalg.norm(velocity)) + + new_pos = self.position + velocity * dt_step + new_pos = np.clip(new_pos, self.amin, self.amax) + + # Boundary constraint + if self._boundary_check is not None and not self._boundary_check(new_pos): + half_pos = self.position + velocity * dt_step * 0.5 + half_pos = np.clip(half_pos, self.amin, self.amax) + if self._boundary_check(half_pos): + new_pos = half_pos + else: + return # hit boundary + + self.position = new_pos.astype(np.float32) + speed = float(np.linalg.norm(self.position - self.path[-1])) if self.path else 0.0 + + # Stuck / weak flow detection + if speed < self._stuck_eps and raw_mag < self._stuck_eps: + self._stuck_counter += 1 + if self._stuck_counter >= self._stuck_threshold and not self._stuck_warned: + self._stuck_warned = True + tag = f" (ghost)" if self.ghost else "" + print(f"\n[probe{self.label}{tag}] Flow is very weak or has ended here. " + f"The probe is stuck at ({self.position[0]:.1f}, {self.position[1]:.1f}, {self.position[2]:.1f}).") + print(f"[probe{self.label}{tag}] Try placing the probe deeper in the brain " + f"where flow is stronger (press 'c' to clear, then 'g' + click).\n") + else: + self._stuck_counter = 0 + + if len(self.path) < self._max_path_len: + self.path.append(self.position.copy()) + self.speeds.append(speed) + self.raw_field_mags.append(raw_mag) + + self.marker_actor.SetPosition(*self.position.tolist()) + + # Append to trail + idx = self._trail_points.InsertNextPoint(*self.position.tolist()) + if idx > 0: + self._trail_cells.InsertNextCell(2) + self._trail_cells.InsertCellPoint(idx - 1) + self._trail_cells.InsertCellPoint(idx) + self._trail_points.Modified() + self._trail_cells.Modified() + self._trail_pd.Modified() + + # Periodic region check + self._step_counter += 1 + self.steps_alive += 1 + if self._step_counter % self._check_interval == 0: + self._update_region_highlights() + + def _point_in_bbox(self, point, bounds): + """Fast bounding-box containment check. bounds is (xmin,xmax,ymin,ymax,zmin,zmax).""" + return (bounds[0] <= point[0] <= bounds[1] and + bounds[2] <= point[1] <= bounds[3] and + bounds[4] <= point[2] <= bounds[5]) + + def _update_region_highlights(self): + if self._mesh_overlay is None or self.position is None: + return + + # --- Raw detection (what region key is at the probe right now?) --- + raw_key = None + if hasattr(self._mesh_overlay, 'get_region_at_point'): + raw_key = self._mesh_overlay.get_region_at_point(self.position) + if raw_key is None and hasattr(self._mesh_overlay, 'find_nearest_region'): + raw_key = self._mesh_overlay.find_nearest_region( + self.position, search_radius=2, + max_distance_mm=self._nearest_max_dist_mm) + else: + for key in self._mesh_overlay.get_all_region_keys(): + if hasattr(self._mesh_overlay, 'fast_point_in_mesh'): + if self._mesh_overlay.fast_point_in_mesh(self.position, key): + raw_key = key + break + elif self._mesh_overlay.point_in_mesh(self.position, key): + raw_key = key + break + + raw_detected = {raw_key} if raw_key else set() + + # --- Debounce: require consecutive detections to enter, consecutive misses to leave --- + # Update hit/miss counters for detected key + for key in raw_detected: + self._region_hit_count[key] = self._region_hit_count.get(key, 0) + 1 + self._region_miss_count.pop(key, None) + + # Update miss counters for keys that were NOT detected this tick + for key in list(self._region_hit_count.keys()): + if key not in raw_detected: + self._region_miss_count[key] = self._region_miss_count.get(key, 0) + 1 + self._region_hit_count[key] = 0 + + # Determine stable set: regions that passed the entry threshold + # and have not yet exceeded the leave threshold + new_regions = set() + for key in set(list(self._region_hit_count.keys()) + + list(self._highlighted_regions)): + hits = self._region_hit_count.get(key, 0) + misses = self._region_miss_count.get(key, 0) + if key in self._highlighted_regions: + # Already highlighted — keep it unless misses exceed threshold + if misses < self._debounce_leave: + new_regions.add(key) + else: + # Not yet highlighted — add if hits exceed entry threshold + if hits >= self._debounce_enter: + new_regions.add(key) + + # Clean up stale counters + for key in list(self._region_miss_count.keys()): + if self._region_miss_count[key] > self._debounce_leave + 2: + self._region_miss_count.pop(key, None) + self._region_hit_count.pop(key, None) + + # --- Apply changes (enter/leave) --- + left = self._highlighted_regions - new_regions + left_names = [] + for key in left: + if key in self._highlight_actors: + try: + self.ren.RemoveActor(self._highlight_actors[key]) + except Exception: + pass + del self._highlight_actors[key] + name = self._mesh_overlay.get_region_name(key) + if hasattr(self._mesh_overlay, 'get_hemisphere_label'): + hemi = self._mesh_overlay.get_hemisphere_label(key, self.position) + if hemi: + name = f"{name} ({hemi})" + left_names.append(name) + tag = " [branch]" if self.ghost else "" + print(f"[probe{self.label}] LEFT: {name}{tag}") + + entered = new_regions - self._highlighted_regions + entered_names = [] + highlight_opacity = 0.12 if self.ghost else 0.25 + for key in entered: + poly = None + if hasattr(self._mesh_overlay, 'get_hemisphere_polydata'): + poly = self._mesh_overlay.get_hemisphere_polydata(key, self.position) + if poly is None: + poly = self._mesh_overlay.get_polydata(key) + if poly is not None: + mapper = vtk.vtkPolyDataMapper() + mapper.SetInputData(poly) + actor = vtk.vtkActor() + actor.SetMapper(mapper) + actor.GetProperty().SetColor(1.0, 0.6, 0.0) # orange (all regions) + actor.GetProperty().SetOpacity(highlight_opacity) + actor.GetProperty().LightingOff() + self.ren.AddActor(actor) + self._highlight_actors[key] = actor + name = self._mesh_overlay.get_region_name(key) + if hasattr(self._mesh_overlay, 'get_hemisphere_label'): + hemi = self._mesh_overlay.get_hemisphere_label(key, self.position) + if hemi: + name = f"{name} ({hemi})" + entered_names.append(name) + tag = " [branch]" if self.ghost else "" + # Detailed entry log with position context + pos_detail = "" + if self.position is not None and self._mesh_overlay is not None: + try: + center = self._mesh_overlay.get_mesh_center(key) + bounds = self._mesh_overlay.get_mesh_bounds(key) + if center is not None and bounds is not None: + extent = [bounds[1]-bounds[0], bounds[3]-bounds[2], + bounds[5]-bounds[4]] + char_size = sum(extent) / 3.0 + dist = float(np.linalg.norm(self.position - center)) + depth = max(0.0, 1.0 - min(dist / (char_size * 0.5), 1.0)) + pos_parts = [] + diff = self.position - center + if abs(diff[2]) > extent[2] * 0.15: + pos_parts.append("dorsal" if diff[2] > 0 else "ventral") + if abs(diff[0]) > extent[0] * 0.15: + pos_parts.append("lateral-R" if diff[0] > 0 else "lateral-L") + if abs(diff[1]) > extent[1] * 0.15: + pos_parts.append("anterior" if diff[1] > 0 else "posterior") + pos_str = "-".join(pos_parts) if pos_parts else "central" + pos_detail = f" [{pos_str}, depth={depth:.0%}]" + except Exception: + pass + print(f"[probe{self.label}{tag}] ENTERED: {name}{pos_detail}") + + self._highlighted_regions = new_regions + self.current_regions = new_regions + + # --- Extra parcellation subregion highlighting --- + # Extra parcellation subregions also get orange, same as main regions. + if (self._mesh_overlay is not None and + hasattr(self._mesh_overlay, '_extra') and + self._mesh_overlay._extra is not None and + self.position is not None and new_regions): + try: + hier = self._mesh_overlay.get_hierarchical_regions_at_point(self.position) + sub = hier.get("subregion") + sub_mesh = hier.get("subregion_mesh") + sub_key = f"_extra_{sub['label_id']}" if sub else None + + # Remove old subregion highlight if changed + old_sub_key = getattr(self, '_current_subregion_key', None) + if old_sub_key and old_sub_key != sub_key: + if old_sub_key in self._highlight_actors: + try: + self.ren.RemoveActor(self._highlight_actors[old_sub_key]) + except Exception: + pass + del self._highlight_actors[old_sub_key] + + # Add new subregion highlight (orange, same as all regions) + if sub_key and sub_mesh and sub_key not in self._highlight_actors: + display_mesh = sub_mesh + try: + bounds = [0.0] * 6 + sub_mesh.GetBounds(bounds) + x_extent = bounds[1] - bounds[0] + if x_extent > 10.0: + x_mid = (bounds[0] + bounds[1]) / 2.0 + plane = vtk.vtkPlane() + plane.SetOrigin(x_mid, 0, 0) + if self.position[0] >= x_mid: + plane.SetNormal(1, 0, 0) + else: + plane.SetNormal(-1, 0, 0) + clipper = vtk.vtkClipPolyData() + clipper.SetInputData(sub_mesh) + clipper.SetClipFunction(plane) + clipper.SetInsideOut(False) + clipper.Update() + clipped = clipper.GetOutput() + if clipped and clipped.GetNumberOfPoints() > 0: + display_mesh = clipped + except Exception: + pass + + mapper = vtk.vtkPolyDataMapper() + mapper.SetInputData(display_mesh) + actor = vtk.vtkActor() + actor.SetMapper(mapper) + actor.GetProperty().SetColor(1.0, 0.6, 0.0) # orange + actor.GetProperty().SetOpacity(0.3) + actor.GetProperty().LightingOff() + self.ren.AddActor(actor) + self._highlight_actors[sub_key] = actor + tag = " [branch]" if self.ghost else "" + hemi_str = "" + if self.position is not None and self.position[0] >= 0: + hemi_str = " (right hemisphere)" + elif self.position is not None: + hemi_str = " (left hemisphere)" + print(f"[probe{self.label}{tag}] SUBREGION: {sub['name']}{hemi_str}") + + # Hide coarser main-atlas regions when we have a finer subregion + if sub_key and sub_key in self._highlight_actors: + hidden = set() + for rkey in new_regions: + if rkey in self._highlight_actors and not rkey.startswith("_extra_"): + self._highlight_actors[rkey].VisibilityOff() + hidden.add(rkey) + self._hidden_orange_keys = hidden + elif not sub_key: + for rkey in getattr(self, '_hidden_orange_keys', set()): + if rkey in self._highlight_actors: + self._highlight_actors[rkey].VisibilityOn() + self._hidden_orange_keys = set() + + self._current_subregion_key = sub_key + except Exception: + pass + + # --- Red hotspot: clip mesh near probe position --- + # Instead of coloring an entire region red, clip the mesh surface + # within a sphere around the probe and show that patch in red. + self._update_hotspot() + + if self._on_region_change and (entered_names or left_names): + self._on_region_change(entered_names, left_names, + is_ghost=self.ghost, label=self.label) + + def _update_hotspot(self): + """Color highlighted region meshes with a distance-based heatmap. + + Vertices near the probe are red, fading smoothly to orange further away. + Uses per-vertex RGBA scalars — no clipping, no extra actors. + """ + from vtkmodules.util.numpy_support import vtk_to_numpy, numpy_to_vtk + + if self.position is None or not self._highlight_actors: + return + + probe_pos = self.position.astype(np.float64) + fade_radius = 15.0 # mm — distance over which red fades to orange + + for key, actor in self._highlight_actors.items(): + mapper = actor.GetMapper() + if mapper is None: + continue + poly = mapper.GetInput() + if poly is None or poly.GetNumberOfPoints() < 3: + continue + + pts_vtk = poly.GetPoints() + if pts_vtk is None: + continue + verts = vtk_to_numpy(pts_vtk.GetData()).astype(np.float64) + + # Distance from each vertex to probe + dists = np.linalg.norm(verts - probe_pos, axis=1) + t = np.clip(dists / fade_radius, 0.0, 1.0) # 0=at probe, 1=far + + # Color gradient: red (1,0,0) at probe → orange (1,0.6,0) far + r = np.full(len(t), 255, np.uint8) + g = (t * 0.6 * 255).astype(np.uint8) + b = np.zeros(len(t), np.uint8) + + # Alpha: brighter near probe, dimmer far away + base_opacity = 0.45 if not self.ghost else 0.2 + far_opacity = 0.2 if not self.ghost else 0.08 + alpha_f = far_opacity + (base_opacity - far_opacity) * (1.0 - t) + a = (np.clip(alpha_f, 0.0, 1.0) * 255).astype(np.uint8) + + rgba = np.column_stack([r, g, b, a]) + scalars = numpy_to_vtk(rgba, deep=True) + scalars.SetNumberOfComponents(4) + scalars.SetName("HeatmapColors") + poly.GetPointData().SetScalars(scalars) + + mapper.SetColorModeToDirectScalars() + mapper.SetScalarModeToUsePointData() + mapper.ScalarVisibilityOn() + actor.GetProperty().LightingOff() + # Override the flat color — let scalars drive everything + actor.GetProperty().SetOpacity(1.0) + poly.Modified() + + def clear(self): + self.active = False + self.position = None + self.path = [] + self.speeds = [] + self.raw_field_mags = [] + self._step_counter = 0 + self.steps_alive = 0 + self.marker_actor.VisibilityOff() + self.trail_actor.VisibilityOff() + self._trail_points = vtk.vtkPoints() + self._trail_cells = vtk.vtkCellArray() + self._trail_pd.SetPoints(self._trail_points) + self._trail_pd.SetLines(self._trail_cells) + self._trail_pd.Modified() + for key, actor in self._highlight_actors.items(): + try: + self.ren.RemoveActor(actor) + except Exception: + pass + self._highlight_actors.clear() + self._highlighted_regions.clear() + self.current_regions.clear() + + def destroy(self): + """Remove all actors from renderer.""" + self.clear() + try: + self.ren.RemoveActor(self.marker_actor) + except Exception: + pass + try: + self.ren.RemoveActor(self.trail_actor) + except Exception: + pass + + def get_path_array(self) -> np.ndarray: + if not self.path: + return np.zeros((0, 3), np.float32) + return np.array(self.path, dtype=np.float32) + + def get_speeds_array(self) -> np.ndarray: + if not self.speeds: + return np.zeros(0, np.float32) + return np.array(self.speeds, dtype=np.float32) + + def get_field_mags_array(self) -> np.ndarray: + if not self.raw_field_mags: + return np.zeros(0, np.float32) + return np.array(self.raw_field_mags, dtype=np.float32) + + +class ProbeSystem: + """Manages single/multi probes and branching behavior. + + Modes: + - single: one probe following mean flow + - multi: N probes in local neighborhood, all following mean flow + - branching: when PI uncertainty is high, spawn ghost probes + + For state-propagation mode, defaults to multi(4) + branching. + """ + + def __init__(self, ren: vtk.vtkRenderer, win: vtk.vtkRenderWindow, + amin: np.ndarray, amax: np.ndarray): + self.ren = ren + self.win = win + self.amin = amin.astype(np.float32) + self.amax = amax.astype(np.float32) + self.probes: list[FlowProbe] = [] + self.ghost_probes: list[FlowProbe] = [] + self._mesh_overlay = None + self._boundary_check = None + self._on_region_change = None + self._branching_enabled = False + self._multi_count = 1 + self._branch_threshold = 0.35 # max ratio between top 2 PI components + self._branch_min_pi = 0.25 # min weight of 2nd component + self._branch_check_interval = 50 + self._branch_step_counter = 0 + self._ghost_color = (0.4, 0.7, 1.0) # pale blue for ghosts + self._pi_field = None + self._mus_samplers = None + + def set_mesh_overlay(self, overlay): + self._mesh_overlay = overlay + + def set_boundary_check(self, fn): + self._boundary_check = fn + + def set_on_region_change(self, fn): + self._on_region_change = fn + + def set_branching(self, enabled: bool, pi_field=None, mus_samplers=None): + """Enable/disable branching. + + Args: + enabled: toggle branching + pi_field: the PI weight grid (G,G,G,K) numpy array + mus_samplers: list of TriLinearSampler for each component + """ + self._branching_enabled = enabled + self._pi_field = pi_field + self._mus_samplers = mus_samplers + print(f"[probe-system] branching {'ON' if enabled else 'OFF'}") + + def set_multi_count(self, n: int): + self._multi_count = max(1, n) + print(f"[probe-system] multi-probe count: {self._multi_count}") + + @property + def active(self) -> bool: + return any(p.active for p in self.probes) + + @property + def path(self): + """Return path of first probe (for backward compat).""" + return self.probes[0].path if self.probes else [] + + def place(self, position: np.ndarray): + """Place probe(s) at position.""" + self.clear() + diag = float(np.linalg.norm(self.amax - self.amin)) + jitter_scale = 0.04 * diag + + for i in range(self._multi_count): + if i == 0: + pos = position.copy() + label = "" if self._multi_count == 1 else f"#{i+1}" + else: + offset = np.random.randn(3).astype(np.float32) * jitter_scale + pos = np.clip(position + offset, self.amin, self.amax) + label = f"#{i+1}" + + probe = FlowProbe(self.ren, self.amin, self.amax, + color=(0.0, 1.0, 0.3), ghost=False, label=label) + if self._mesh_overlay: + probe.set_mesh_overlay(self._mesh_overlay) + if self._boundary_check: + probe.set_boundary_check(self._boundary_check) + if self._on_region_change: + probe.set_on_region_change(self._on_region_change) + probe.place(pos) + self.probes.append(probe) + + def step(self, sampler, dt_step: float, pi_sampler=None): + """Step all probes.""" + for p in self.probes: + if p.active: + p.step(sampler, dt_step) + + for g in self.ghost_probes: + if g.active: + # Ghost probes follow their specific component sampler + comp_sampler = getattr(g, '_comp_sampler', sampler) + g.step(comp_sampler, dt_step) + + # Prune ghost probes that have been alive > 200 steps but did not diverge + diag = float(np.linalg.norm(self.amax - self.amin)) + prune_dist = 0.03 * diag + to_remove = [] + for g in self.ghost_probes: + if not g.active or g.position is None: + continue + if g.steps_alive > 200: + for p in self.probes: + if not p.active or p.position is None: + continue + dist = float(np.linalg.norm(g.position - p.position)) + if dist < prune_dist: + print("[branch] pruned ghost - did not diverge") + to_remove.append(g) + break + for g in to_remove: + g.destroy() + self.ghost_probes.remove(g) + + # Check for branching + if self._branching_enabled: + self._branch_step_counter += 1 + if self._branch_step_counter % self._branch_check_interval == 0: + self._check_branching(sampler, dt_step) + + def _check_branching(self, mean_sampler, dt_step): + """Check if any active probe is at a high-uncertainty location and branch.""" + if self._pi_field is None or self._mus_samplers is None: + return + if not self.probes: + return + + from .field_loader import TriLinearSampler + + for p in self.probes: + if not p.active or p.position is None: + continue + # Sample PI weights at probe position + pos = p.position[None, :] + pi_vals = mean_sampler.sample_vec(pos) # dummy, we need PI + # Actually sample PI field directly + try: + K = self._pi_field.shape[-1] + weights = np.zeros(K, np.float32) + for k in range(K): + pi_k = self._pi_field[..., k:k+1] + # Use sampler to interpolate + w = mean_sampler.sample_scalar( + self._pi_field[..., k], + p.position[None, :] + ) + weights[k] = float(w[0]) + except Exception: + continue + + # Normalize + ws = weights.sum() + if ws <= 0: + continue + weights /= ws + + # Sort to find top 2 + sorted_idx = np.argsort(weights)[::-1] + w1 = weights[sorted_idx[0]] + w2 = weights[sorted_idx[1]] if len(sorted_idx) > 1 else 0.0 + + # Check if uncertain enough + if w2 < self._branch_min_pi: + continue + ratio = w2 / max(w1, 1e-9) + if ratio < self._branch_threshold: + continue + + # Check we haven't already branched from near this position + already_branched = False + for g in self.ghost_probes: + if g.active and g.path: + d = np.linalg.norm(g.path[0] - p.position) + if d < 0.02 * float(np.linalg.norm(self.amax - self.amin)): + already_branched = True + break + if already_branched: + continue + + # Branch! Create ghost probe following 2nd component + comp_idx = int(sorted_idx[1]) + if comp_idx >= len(self._mus_samplers): + continue + + ghost = FlowProbe(self.ren, self.amin, self.amax, + color=self._ghost_color, ghost=True, + label=f"~branch(comp{comp_idx+1})") + if self._mesh_overlay: + ghost.set_mesh_overlay(self._mesh_overlay) + if self._boundary_check: + ghost.set_boundary_check(self._boundary_check) + if self._on_region_change: + ghost.set_on_region_change(self._on_region_change) + ghost._comp_sampler = self._mus_samplers[comp_idx] + ghost.place(p.position.copy()) + self.ghost_probes.append(ghost) + + print(f"[branch] SPLIT at ({p.position[0]:.2f}, {p.position[1]:.2f}, " + f"{p.position[2]:.2f}): comp{sorted_idx[0]+1}={w1:.2f} vs " + f"comp{comp_idx+1}={w2:.2f}") + if self._on_region_change: + self._on_region_change( + [f"BRANCH: comp{comp_idx+1} (weight={w2:.2f})"], [], + is_ghost=True, label="branch" + ) + + def clear(self): + """Clear all probes.""" + for p in self.probes: + p.destroy() + for g in self.ghost_probes: + g.destroy() + self.probes.clear() + self.ghost_probes.clear() + self._branch_step_counter = 0 + + def freeze(self): + """Stop all probes from moving (keep path and highlights intact).""" + for p in self.probes: + p.active = False + for g in self.ghost_probes: + g.active = False + print("[probe-system] probes frozen") + + def get_all_probes(self) -> list[FlowProbe]: + """Return all probes (main + ghost) for analysis.""" + return self.probes + self.ghost_probes + + def get_primary_probe(self) -> FlowProbe | None: + """Return the first active probe.""" + for p in self.probes: + if p.active: + return p + return None + + def get_all_paths(self) -> list[tuple[np.ndarray, bool, str]]: + """Return [(path_array, is_ghost, label), ...] for all probes.""" + result = [] + for p in self.probes: + if p.path: + result.append((p.get_path_array(), False, p.label)) + for g in self.ghost_probes: + if g.path: + result.append((g.get_path_array(), True, g.label)) + return result diff --git a/src/region_analyzer.py b/src/region_analyzer.py new file mode 100644 index 0000000000000000000000000000000000000000..9472f7bf3989b4e2c7bf3b744b86822a6d8af819 --- /dev/null +++ b/src/region_analyzer.py @@ -0,0 +1,703 @@ +"""Analyze probe trajectory through brain regions and explain transitions via GPT.""" + +import json +import os +from pathlib import Path + +import numpy as np + +# Path to RAG knowledge files (JSON and TXT files with brain region descriptions) +RAG_KNOWLEDGE_DIR = Path(__file__).resolve().parent.parent / "data" / "rag_knowledge" + + + +def load_knowledge_from_files(directory: Path | None = None) -> dict[str, str]: + """Load brain region knowledge from user-supplied JSON and TXT files. + + JSON files: {"Region Name": "description", ...} + TXT files: sections separated by '## Region Name' headers. + + Returns merged dict. Empty dict if directory missing/empty. + """ + directory = directory or RAG_KNOWLEDGE_DIR + knowledge: dict[str, str] = {} + if not directory.is_dir(): + return knowledge + + # Process files in sorted order for deterministic merging + for fpath in sorted(directory.iterdir()): + if fpath.suffix == ".json": + try: + data = json.loads(fpath.read_text(encoding="utf-8")) + if isinstance(data, dict): + count = 0 + for k, v in data.items(): + if isinstance(k, str) and isinstance(v, str): + knowledge[k] = v + count += 1 + if count: + print(f"[RAG] Loaded {count} regions from {fpath.name}") + except Exception as e: + print(f"[RAG] Warning: could not load {fpath.name}: {e}") + + elif fpath.suffix == ".txt": + try: + text = fpath.read_text(encoding="utf-8") + current_region = None + current_lines: list[str] = [] + count = 0 + for line in text.splitlines(): + if line.startswith("## "): + if current_region and current_lines: + knowledge[current_region] = " ".join(current_lines).strip() + count += 1 + current_region = line[3:].strip() + current_lines = [] + elif current_region is not None: + current_lines.append(line.strip()) + if current_region and current_lines: + knowledge[current_region] = " ".join(current_lines).strip() + count += 1 + if count: + print(f"[RAG] Loaded {count} regions from {fpath.name}") + except Exception as e: + print(f"[RAG] Warning: could not load {fpath.name}: {e}") + + return knowledge + + +def _call_direct_responses_api(question: str, model: str) -> str: + """Direct OpenAI Responses API call for no-RAG mode. + + Avoids the LangChain empty-content failure path seen with GPT-5.4 in the + background thread. + """ + _ensure_ssl() + + from dotenv import load_dotenv + load_dotenv() + + from openai import OpenAI + + client = OpenAI(timeout=60.0) + + instructions = ( + "You are a neuroscience expert interpreting information flow in a " + "resting-state brain.\n\n" + "Do NOT repeat the region list.\n" + "Do explain:\n" + "1. what specific information is likely flowing,\n" + "2. how the signal is transformed across regions,\n" + "3. what spontaneous resting-state process could produce this path,\n" + "4. the functional purpose of this exact trajectory.\n" + "Be concrete, not generic." + ) + + resp = client.responses.create( + model=model, + instructions=instructions, + input=question, + # This is not a model downgrade. It just keeps enough budget for the visible answer. + reasoning={"effort": "low"}, + max_output_tokens=2500, + # Optional, if your SDK/version accepts it and you want longer answers: + # text={"verbosity": "high"}, + ) + + text = (resp.output_text or "").strip() + if text: + return text + + if getattr(resp, "status", None) == "incomplete" and getattr(resp, "incomplete_details", None): + reason = getattr(resp.incomplete_details, "reason", "unknown") + return f"[GPT ERROR] Responses API incomplete: {reason}" + + return "[GPT ERROR] Responses API returned no visible text." + +def _get_mesh_volume(mesh_overlay, key: str) -> float: + """Estimate mesh volume from bounding box (for nesting detection).""" + bounds = mesh_overlay.get_mesh_bounds(key) + if bounds is None: + return float('inf') + return float((bounds[1] - bounds[0]) * (bounds[3] - bounds[2]) * (bounds[5] - bounds[4])) + + +def analyze_probe_path(path_positions: np.ndarray, mesh_overlay, + sample_every: int = 10, + field_mags: np.ndarray | None = None, + entropy_sampler=None, + entropy_field=None) -> list[dict]: + """Analyze which brain regions the probe traveled through. + + Handles nested regions: when the probe is inside multiple overlapping + regions (e.g., a subregion inside a larger area), the smaller region + is treated as the primary region and the larger one as context. + + Args: + path_positions: (N,3) probe path + mesh_overlay: FlowMeshOverlay instance + sample_every: check every N steps + field_mags: raw field magnitudes (independent of speed scale) + entropy_sampler: TriLinearSampler for entropy + entropy_field: entropy grid (G,G,G) numpy array + + Returns a list of dicts with keys: region_key, region_name, entry_idx, + exit_idx, relative_position, avg_flow_strength, avg_entropy, + enclosing_regions. + """ + if len(path_positions) == 0: + return [] + + region_keys = mesh_overlay.get_all_region_keys() + # Pre-compute volumes for nesting detection + volumes = {k: _get_mesh_volume(mesh_overlay, k) for k in region_keys} + + transitions = [] + prev_regions = set() + + for i in range(0, len(path_positions), sample_every): + pt = path_positions[i] + in_regions = set() + + for key in region_keys: + if mesh_overlay.point_in_mesh(pt, key): + in_regions.add(key) + + # Sample entropy at this point + ent_val = None + if entropy_sampler is not None and entropy_field is not None: + try: + ent_val = float(entropy_sampler.sample_scalar(entropy_field, pt[None, :])[0]) + except Exception: + pass + + # Detect entries + entered = in_regions - prev_regions + for key in entered: + center = mesh_overlay.get_mesh_center(key) + rel_pos = _relative_position(pt, center, mesh_overlay.get_mesh_bounds(key)) + + # Find enclosing (larger) regions at this point + enclosing = [] + for other_key in in_regions: + if other_key != key and volumes.get(other_key, 0) > volumes.get(key, 0): + enclosing.append(mesh_overlay.get_region_name(other_key)) + + # Flow strength (independent of speed scale) + flow_str = None + if field_mags is not None and i < len(field_mags): + flow_str = float(field_mags[i]) + + # Hemisphere info (if mesh_overlay supports it) + hemisphere = None + if hasattr(mesh_overlay, 'get_hemisphere_label'): + try: + hemisphere = mesh_overlay.get_hemisphere_label(key, pt) + except Exception: + pass + + transition_dict = { + "region_key": key, + "region_name": mesh_overlay.get_region_name(key), + "entry_idx": i, + "exit_idx": None, + "relative_position": rel_pos, + "entry_point": pt.copy(), + "enclosing_regions": enclosing, + "avg_flow_strength": flow_str, + "avg_entropy": ent_val, + "_flow_samples": [flow_str] if flow_str is not None else [], + "_ent_samples": [ent_val] if ent_val is not None else [], + } + if hemisphere is not None: + transition_dict["hemisphere"] = hemisphere + + # Extra parcellation subregion info + if hasattr(mesh_overlay, '_extra') and mesh_overlay._extra is not None: + sub = mesh_overlay._extra.get_region_at_point(pt) + if sub is not None: + transition_dict["subregion_name"] = sub["name"] + transition_dict["subregion_volume"] = sub["volume_mm3"] + + transitions.append(transition_dict) + + # Accumulate samples for active transitions + for t in transitions: + if t["exit_idx"] is None: + if field_mags is not None and i < len(field_mags): + t["_flow_samples"].append(float(field_mags[i])) + if ent_val is not None: + t["_ent_samples"].append(ent_val) + + # Detect exits + exited = prev_regions - in_regions + for key in exited: + for t in reversed(transitions): + if t["region_key"] == key and t["exit_idx"] is None: + t["exit_idx"] = i + if t["_flow_samples"]: + t["avg_flow_strength"] = float(np.mean(t["_flow_samples"])) + if t["_ent_samples"]: + t["avg_entropy"] = float(np.mean(t["_ent_samples"])) + break + + prev_regions = in_regions + + # Close any still-open regions + for t in transitions: + if t["exit_idx"] is None: + t["exit_idx"] = len(path_positions) - 1 + if t["_flow_samples"]: + t["avg_flow_strength"] = float(np.mean(t["_flow_samples"])) + if t["_ent_samples"]: + t["avg_entropy"] = float(np.mean(t["_ent_samples"])) + + # Pre-compute sampled path points for nearby detection / enrichment + sampled_pts = path_positions[::sample_every] + + # --- Enrich each transition with detailed interaction context --- + for t in transitions: + key = t["region_key"] + entry_i = t["entry_idx"] + exit_i = t["exit_idx"] if t["exit_idx"] is not None else len(path_positions) - 1 + + center = mesh_overlay.get_mesh_center(key) + bounds = mesh_overlay.get_mesh_bounds(key) + + # Collect all path points inside this region's span + inside_pts = path_positions[entry_i:exit_i + 1:sample_every] + + # 1. Interaction type: how deeply the probe penetrated + if center is not None and bounds is not None and len(inside_pts) > 0: + extent = np.array([bounds[1]-bounds[0], bounds[3]-bounds[2], + bounds[5]-bounds[4]]) + extent = np.maximum(extent, 1e-6) + char_size = float(np.mean(extent)) # characteristic region size + + # Distance from each inside point to region center + dists_to_center = np.linalg.norm(inside_pts - center, axis=1) + min_dist = float(dists_to_center.min()) + mean_dist = float(dists_to_center.mean()) + # Normalized penetration depth (0 = at center, 1 = at edge) + penetration = 1.0 - min(min_dist / (char_size * 0.5), 1.0) + + if penetration > 0.6: + interaction = "passed through center" + elif penetration > 0.3: + interaction = "traversed mid-region" + elif len(inside_pts) <= 2: + interaction = "briefly touched surface" + else: + interaction = "passed through periphery" + t["interaction_type"] = interaction + t["penetration_depth"] = round(penetration, 2) + else: + t["interaction_type"] = "traversed" + t["penetration_depth"] = None + + # 2. Exit position (relative to region) + if exit_i < len(path_positions) and center is not None and bounds is not None: + exit_pt = path_positions[min(exit_i, len(path_positions) - 1)] + t["exit_position"] = _relative_position(exit_pt, center, bounds) + else: + t["exit_position"] = "unknown" + + # 3. Traversal direction through the region + if len(inside_pts) >= 2: + direction_vec = inside_pts[-1] - inside_pts[0] + norm = np.linalg.norm(direction_vec) + if norm > 1e-6: + d = direction_vec / norm + dir_parts = [] + if abs(d[2]) > 0.3: + dir_parts.append("upward" if d[2] > 0 else "downward") + if abs(d[0]) > 0.3: + dir_parts.append("rightward" if d[0] > 0 else "leftward") + if abs(d[1]) > 0.3: + dir_parts.append("anteriorly" if d[1] > 0 else "posteriorly") + t["traversal_direction"] = " and ".join(dir_parts) if dir_parts else "through" + else: + t["traversal_direction"] = "stationary" + else: + t["traversal_direction"] = "brief" + + # --- Detect nearby regions (probe flowed close but never entered) --- + entered_keys = {t["region_key"] for t in transitions} + NEARBY_THRESHOLD_MM = 5.0 # max distance to consider "nearby" + nearby_transitions = [] + + for key in region_keys: + if key in entered_keys: + continue + center = mesh_overlay.get_mesh_center(key) + bounds = mesh_overlay.get_mesh_bounds(key) + if center is None or bounds is None: + continue + extent = np.array([bounds[1]-bounds[0], bounds[3]-bounds[2], + bounds[5]-bounds[4]]) + extent = np.maximum(extent, 1e-6) + char_size = float(np.mean(extent)) + + # Check minimum distance from any sampled path point to region center + dists = np.linalg.norm(sampled_pts - center, axis=1) + min_dist = float(dists.min()) + # "nearby" = within threshold but also within the region's own characteristic size + effective_radius = max(char_size * 0.5, NEARBY_THRESHOLD_MM) + if min_dist < effective_radius: + closest_idx = int(dists.argmin()) * sample_every + name = mesh_overlay.get_region_name(key) + hemisphere = None + if hasattr(mesh_overlay, 'get_hemisphere_label'): + try: + hemisphere = mesh_overlay.get_hemisphere_label( + key, sampled_pts[int(dists.argmin())]) + except Exception: + pass + nearby_t = { + "region_key": key, + "region_name": name, + "entry_idx": closest_idx, + "exit_idx": closest_idx, + "relative_position": _relative_position( + sampled_pts[int(dists.argmin())], center, bounds), + "entry_point": sampled_pts[int(dists.argmin())].copy(), + "enclosing_regions": [], + "avg_flow_strength": None, + "avg_entropy": None, + "interaction_type": f"nearby (did not enter, ~{min_dist:.1f}mm away)", + "penetration_depth": 0.0, + "exit_position": "n/a", + "traversal_direction": "n/a", + } + if hemisphere is not None: + nearby_t["hemisphere"] = hemisphere + nearby_transitions.append(nearby_t) + + transitions.extend(nearby_transitions) + + # Clean up internal fields + for t in transitions: + t.pop("_flow_samples", None) + t.pop("_ent_samples", None) + + # Sort by volume (smaller = more specific) so primary regions come first at each transition + transitions.sort(key=lambda t: (t["entry_idx"], volumes.get(t["region_key"], float('inf')))) + + return transitions + + +def _relative_position(point: np.ndarray, center: np.ndarray | None, + bounds: np.ndarray | None) -> str: + """Describe position relative to region center (e.g., 'upper-left').""" + if center is None or bounds is None: + return "unknown" + + diff = point - center + extent = np.array([ + bounds[1] - bounds[0], + bounds[3] - bounds[2], + bounds[5] - bounds[4], + ]) + extent = np.maximum(extent, 1e-6) + rel = diff / (extent * 0.5) # normalized to [-1, 1] + + parts = [] + if abs(rel[2]) > 0.3: + parts.append("upper" if rel[2] > 0 else "lower") + if abs(rel[0]) > 0.3: + parts.append("right" if rel[0] > 0 else "left") + if abs(rel[1]) > 0.3: + parts.append("anterior" if rel[1] > 0 else "posterior") + + if not parts: + return "center" + return "-".join(parts) + + +def format_transitions_text(transitions: list[dict], + include_branches: list[dict] | None = None) -> str: + """Format transitions into a human-readable description for GPT. + + Handles nested regions: smaller (more specific) regions are primary, + with larger enclosing regions noted as context. + """ + if not transitions: + return "The probe did not pass through any identified brain regions." + + lines = ["The probe traveled through the following brain regions in order:\n"] + for i, t in enumerate(transitions, 1): + duration = (t["exit_idx"] or 0) - (t["entry_idx"] or 0) + + # Include hemisphere in region name if available + region_label = t['region_name'] + hemisphere = t.get("hemisphere") + if hemisphere: + region_label = f"{region_label} ({hemisphere})" + + extras = [] + if t.get("avg_flow_strength") is not None: + extras.append(f"flow_strength={t['avg_flow_strength']:.4f}") + if t.get("avg_entropy") is not None: + extras.append(f"model_entropy={t['avg_entropy']:.3f} " + f"({'high uncertainty' if t['avg_entropy'] > 1.5 else 'confident'})") + extras_str = f", {', '.join(extras)}" if extras else "" + + enclosing = t.get("enclosing_regions", []) + ctx_str = "" + if enclosing: + ctx_str = f" [within larger region: {', '.join(enclosing)}]" + + # Extra parcellation subregion info + subregion = t.get("subregion_name") + sub_str = "" + if subregion: + sub_str = f", more specifically in subregion: {subregion}" + + # Interaction detail from enrichment + interaction = t.get("interaction_type", "traversed") + is_nearby = "nearby" in interaction + + if is_nearby: + # Nearby region: compact format, no penetration/direction/exit noise + entry_pos = t.get("relative_position", "unknown") + pos_str = f" near {entry_pos} section" if entry_pos and entry_pos != "unknown" else "" + lines.append( + f"{i}. {region_label} — {interaction}{pos_str}{ctx_str}{sub_str}" + ) + else: + penetration = t.get("penetration_depth") + direction = t.get("traversal_direction", "") + exit_pos = t.get("exit_position", "") + + # Build detailed description + detail_parts = [f"{interaction}"] + if penetration is not None: + detail_parts.append(f"penetration depth {penetration:.0%}") + if direction and direction not in ("brief", "stationary", "through", "n/a"): + detail_parts.append(f"moving {direction}") + detail_str = ", ".join(detail_parts) + + entry_pos = t.get("relative_position", "unknown") + exit_str = f", exited from {exit_pos}" if exit_pos and exit_pos not in ("unknown", "n/a") else "" + + lines.append( + f"{i}. {region_label} — {detail_str}. " + f"Entered at {entry_pos}{exit_str}, " + f"~{duration} steps inside{extras_str}{ctx_str}{sub_str}" + ) + + if include_branches: + lines.append("\nBranching events (alternative flow paths detected):") + for b in include_branches: + lines.append(f" - Branch at step ~{b.get('entry_idx', '?')}: " + f"flow split toward {b.get('region_name', 'unknown')}") + + return "\n".join(lines) + + +def build_rag_documents(): + """Build LangChain documents from the RAG knowledge base. + + Loads all JSON and TXT files from data/rag_knowledge/. + If no files are found, the RAG chain will have no context and the + LLM will rely on its own training knowledge. + """ + from langchain_core.documents import Document + + knowledge = load_knowledge_from_files() + if not knowledge: + print("[RAG] Warning: no knowledge files found in data/rag_knowledge/. " + "LLM will use its own training knowledge only.") + + docs = [] + for name, desc in knowledge.items(): + docs.append(Document( + page_content=f"Brain Region: {name}\nFunction: {desc}", + metadata={"region_name": name} + )) + return docs + + +def _ensure_ssl(): + """Fix SSL cert path for environments where Git overrides it.""" + try: + import certifi + cert_file = certifi.where() + # Force override if current path is missing (Git installer on Windows + # often sets SSL_CERT_FILE to a non-existent path) + cur = os.environ.get("SSL_CERT_FILE", "") + if not cur or not os.path.isfile(cur): + os.environ["SSL_CERT_FILE"] = cert_file + cur2 = os.environ.get("REQUESTS_CA_BUNDLE", "") + if not cur2 or not os.path.isfile(cur2): + os.environ["REQUESTS_CA_BUNDLE"] = cert_file + except ImportError: + pass + + +def create_rag_chain(model: str = "gpt-5.4-mini"): + """Create a LangChain RAG chain with region knowledge.""" + _ensure_ssl() + + from dotenv import load_dotenv + load_dotenv() + + from langchain_openai import ChatOpenAI, OpenAIEmbeddings + from langchain_community.vectorstores import FAISS + from langchain_core.prompts import ChatPromptTemplate + from langchain_core.runnables import RunnablePassthrough + from langchain_core.output_parsers import StrOutputParser + + docs = build_rag_documents() + + embeddings = OpenAIEmbeddings(model="text-embedding-3-small") + vectorstore = FAISS.from_documents(docs, embeddings) + retriever = vectorstore.as_retriever(search_kwargs={"k": 10}) + + prompt = ChatPromptTemplate.from_template( + """You are a neuroscience expert interpreting information flow in a resting-state brain. + +Reference knowledge about relevant brain regions: +{context} + +{question} + +CRITICAL INSTRUCTIONS — read carefully before answering: +1. Do NOT list which regions the probe passed through — the user already knows that. +2. Do NOT give a generic answer like "this flow is associated with the default mode network" — that is too shallow. +3. DO explain what SPECIFIC INFORMATION is likely flowing along this exact path. What is being computed, processed, or communicated? How does the information CHANGE as it passes through each region? +4. DO describe the functional MEANING of this particular flow trajectory: why would information flow in this exact direction, through these specific regions, in this order? What is the computational purpose? +5. DO explain how each region transforms the signal before passing it on — not just what each region "does" in general, but what it does to THIS specific incoming signal. +6. DO consider laterality: if the flow is in the left or right hemisphere, explain what that implies about the type of processing (e.g., left-lateralized language processing, right-lateralized spatial processing). +7. This is a RESTING-STATE brain — describe what spontaneous cognitive process would produce this exact flow. Be concrete: "the brain is likely consolidating a recent spatial memory" is better than "this involves memory processing." + +Note: the flow direction reflects rDCM effective connectivity (who influences whom), not neural signal timing. Particle speed is a visualization scale, not biological latency. +""" + ) + + llm = ChatOpenAI(model=model, temperature=0.3, max_tokens=1500) + + def format_docs(docs): + return "\n\n".join(doc.page_content for doc in docs) + + chain = ( + {"context": retriever | format_docs, "question": RunnablePassthrough()} + | prompt + | llm + | StrOutputParser() + ) + + return chain + + +def create_direct_chain(model: str = "gpt-5.4-mini"): + """Create a direct GPT chain without RAG (uses only GPT's own knowledge).""" + _ensure_ssl() + + from dotenv import load_dotenv + load_dotenv() + + from langchain_openai import ChatOpenAI + from langchain_core.prompts import ChatPromptTemplate + from langchain_core.runnables import RunnablePassthrough + from langchain_core.output_parsers import StrOutputParser + + prompt = ChatPromptTemplate.from_template( + """You are a neuroscience expert interpreting information flow in a resting-state brain. + +Using your deep knowledge of neuroanatomy, functional connectivity, and resting-state \ +networks, answer the following question: + +{question} + +CRITICAL INSTRUCTIONS — read carefully before answering: +1. Do NOT list which regions the probe passed through — the user already knows that. +2. Do NOT give a generic answer like "this flow is associated with the default mode network" — that is too shallow. +3. DO explain what SPECIFIC INFORMATION is likely flowing along this exact path. What is being computed, processed, or communicated? How does the information CHANGE as it passes through each region? +4. DO describe the functional MEANING of this particular flow trajectory: why would information flow in this exact direction, through these specific regions, in this order? What is the computational purpose? +5. DO explain how each region transforms the signal before passing it on — not just what each region "does" in general, but what it does to THIS specific incoming signal. +6. DO consider laterality: if the flow is in the left or right hemisphere, explain what that implies about the type of processing (e.g., left-lateralized language processing, right-lateralized spatial processing). +7. This is a RESTING-STATE brain — describe what spontaneous cognitive process would produce this exact flow. Be concrete: "the brain is likely consolidating a recent spatial memory" is better than "this involves memory processing." + +Note: the flow direction reflects rDCM effective connectivity (who influences whom), not neural signal timing. Particle speed is a visualization scale, not biological latency. +""" + ) + + llm = ChatOpenAI(model=model, temperature=0.3, max_tokens=1500) + + chain = ( + {"question": RunnablePassthrough()} + | prompt + | llm + | StrOutputParser() + ) + + return chain + + +_rag_chain = None +_direct_chain = None +_cached_model = None + + +def analyze_with_gpt(transitions: list[dict], use_rag: bool = True, + model: str = "gpt-5.4-mini", + debug: bool = False) -> str: + global _rag_chain, _direct_chain, _cached_model + + if not transitions: + return "No brain regions were traversed. Place the probe inside the brain volume." + + description = format_transitions_text(transitions) + question = ( + f"A probe was placed in a resting-state brain and followed the intrinsic information " + f"flow field through these regions:\n\n{description}\n\n" + f"Analyze this SPECIFIC flow pathway in depth:\n" + f"1. What concrete information is likely being carried along this path?\n" + f"2. How does the information CHANGE and get TRANSFORMED as it passes through each region?\n" + f"3. What spontaneous resting-state cognitive process would produce this EXACT flow?\n" + f"4. What is the functional PURPOSE of information flowing in this exact direction and order?\n" + f"Do NOT repeat the region list or give a generic network label. Give deep, specific insight.\n" + f"Keep your response SHORT and focused — 2-3 concise paragraphs maximum." + ) + + if debug: + import sys + print(f"\n{'='*60}") + print(f"[DEBUG PROMPT] analyze_with_gpt (use_rag={use_rag}, model={model})") + print(f"{'='*60}") + print(question) + print(f"{'='*60}\n") + sys.stdout.flush() + + # IMPORTANT: bypass LangChain entirely for no-RAG mode + if not use_rag: + try: + print(f"[GPT] Using Responses API directly (model={model}, no RAG)...") + return _call_direct_responses_api(question, model) + except Exception as e: + import traceback + traceback.print_exc() + return f"[GPT ERROR] Direct Responses API failed: {e}" + + # keep existing RAG path for now + if _cached_model != model: + _rag_chain = None + _direct_chain = None + _cached_model = model + + if _rag_chain is None: + print(f"[GPT] Initializing RAG chain (model={model})...") + try: + _rag_chain = create_rag_chain(model=model) + except Exception as e: + return f"[GPT ERROR] Failed to initialize RAG: {e}" + + try: + result = _rag_chain.invoke(question) + text = result if isinstance(result, str) else str(result) + return text.strip() if text and text.strip() else "[GPT ERROR] Empty RAG response." + except Exception as e: + import traceback + traceback.print_exc() + return f"[GPT ERROR] {e}" diff --git a/src/roi_flow.py b/src/roi_flow.py new file mode 100644 index 0000000000000000000000000000000000000000..e9e2cd1509e42529c5d0ec1d503da05b03d11ec5 --- /dev/null +++ b/src/roi_flow.py @@ -0,0 +1,403 @@ +"""ROI Flow mode: manifold-to-ROI mapping and LLM interpretation. + +Maps probe positions in a learned neural manifold to ROI activation vectors +using k-nearest-neighbor interpolation, then analyzes how the ROI pattern +changes along the probe path. + +This module is used by examples/roi_flow_mode.py for the dual-window +manifold + ROI visualization. +""" + +import os +from pathlib import Path + +import numpy as np +from scipy.spatial import cKDTree + + +# --------------------------------------------------------------------------- +# Manifold-to-ROI mapper +# --------------------------------------------------------------------------- + +class ManifoldToROIKNN: + """Map manifold positions to ROI activation vectors via kNN + Gaussian weighting. + + Given a library of (embedding_point, roi_vector) pairs, interpolates + the ROI vector at any manifold position using Gaussian-weighted kNN. + """ + + def __init__(self, embed_points: np.ndarray, roi_vectors: np.ndarray, + k: int = 256, sigma: float = 0.0): + """ + Args: + embed_points: (N, D) embedding coordinates (typically D=3). + roi_vectors: (N, R) ROI activation vectors. + k: number of nearest neighbors for interpolation. + sigma: Gaussian bandwidth. If 0, auto-computed from median kNN distance. + """ + assert embed_points.shape[0] == roi_vectors.shape[0], \ + f"embed ({embed_points.shape[0]}) and roi ({roi_vectors.shape[0]}) must match" + self.X = embed_points.astype(np.float32) + self.Y = roi_vectors.astype(np.float32) + self.k = int(max(8, k)) + self.sigma = float(max(0.0, sigma)) + self.n_rois = self.Y.shape[1] + self.tree = cKDTree(self.X) + + def query(self, point: np.ndarray) -> np.ndarray: + """Interpolate ROI vector at a manifold position. + + Args: + point: (D,) or (1,D) position in manifold space. + + Returns: + (R,) ROI activation vector. + """ + p = point.reshape(1, -1).astype(np.float32) + k_actual = min(self.k, len(self.X)) + d, idx = self.tree.query(p, k=k_actual) + d = np.asarray(d).ravel().astype(np.float32) + idx = np.asarray(idx).ravel() + + if len(idx) == 0: + return np.zeros(self.n_rois, dtype=np.float32) + + sig = self.sigma if self.sigma > 0 else float(np.median(d) + 1e-9) + w = np.exp(-(d ** 2) / (2.0 * sig ** 2)).astype(np.float64) + sw = float(np.sum(w)) + 1e-12 + mu = (w[:, None] * self.Y[idx]).sum(axis=0) / sw + return mu.astype(np.float32) + + +# --------------------------------------------------------------------------- +# ROI flow analyzer +# --------------------------------------------------------------------------- + +class ROIFlowAnalyzer: + """Analyze ROI delta vectors to extract flow patterns for LLM interpretation.""" + + def __init__(self, roi_names: list[str], roi_centers: np.ndarray): + """ + Args: + roi_names: (R,) human-readable ROI names. + roi_centers: (R, 3) MNI coordinates of ROI centers. + """ + self.roi_names = roi_names + self.roi_centers = roi_centers.astype(np.float32) + self.n_rois = len(roi_names) + + def compute_delta(self, start_roi: np.ndarray, end_roi: np.ndarray) -> np.ndarray: + """Compute change in ROI activation from start to end of path.""" + return (end_roi - start_roi).astype(np.float32) + + def analyze_flow_pattern(self, delta: np.ndarray) -> dict: + """Extract structured information about the ROI flow pattern. + + Returns dict with: + top_positive: list of (name, delta_val, center) for top increased ROIs + top_negative: list of (name, delta_val, center) for top decreased ROIs + direction: dict with anterior_posterior, left_right, superior_inferior scores + pattern_type: "one_to_many", "many_to_one", "distributed", "bilateral_split" + bulk_direction: human-readable description of dominant flow direction + """ + abs_delta = np.abs(delta) + threshold = np.percentile(abs_delta, 85) + + # Top changed ROIs + significant = abs_delta > threshold + pos_mask = (delta > 0) & significant + neg_mask = (delta < 0) & significant + + pos_indices = np.where(pos_mask)[0] + neg_indices = np.where(neg_mask)[0] + + # Sort by magnitude + pos_sorted = pos_indices[np.argsort(-delta[pos_indices])] + neg_sorted = neg_indices[np.argsort(delta[neg_indices])] + + top_positive = [(self.roi_names[i], float(delta[i]), + self.roi_centers[i].tolist()) for i in pos_sorted[:10]] + top_negative = [(self.roi_names[i], float(delta[i]), + self.roi_centers[i].tolist()) for i in neg_sorted[:10]] + + # Compute directional bias using weighted centroids + pos_weighted_center = np.zeros(3) + neg_weighted_center = np.zeros(3) + if len(pos_indices) > 0: + w = delta[pos_indices] + pos_weighted_center = np.average(self.roi_centers[pos_indices], axis=0, + weights=w) + if len(neg_indices) > 0: + w = np.abs(delta[neg_indices]) + neg_weighted_center = np.average(self.roi_centers[neg_indices], axis=0, + weights=w) + + # Direction: from negative (source) to positive (target) centroids + flow_vec = pos_weighted_center - neg_weighted_center + direction = { + "left_right": float(flow_vec[0]), # +X = right + "anterior_posterior": float(flow_vec[1]), # +Y = anterior + "superior_inferior": float(flow_vec[2]), # +Z = superior + } + + # Pattern type + n_pos = len(pos_indices) + n_neg = len(neg_indices) + if n_neg <= 3 and n_pos > 8: + pattern_type = "one_to_many" + elif n_pos <= 3 and n_neg > 8: + pattern_type = "many_to_one" + elif n_pos > 0 and n_neg > 0: + # Check bilateral split + pos_x = self.roi_centers[pos_indices, 0] + neg_x = self.roi_centers[neg_indices, 0] + pos_mean_x = float(np.mean(pos_x)) + neg_mean_x = float(np.mean(neg_x)) + if abs(pos_mean_x - neg_mean_x) > 20: # significant L/R separation + pattern_type = "bilateral_split" + else: + pattern_type = "distributed" + else: + pattern_type = "distributed" + + # Human-readable bulk direction + parts = [] + if abs(direction["anterior_posterior"]) > 10: + parts.append("anterior" if direction["anterior_posterior"] > 0 else "posterior") + if abs(direction["left_right"]) > 10: + parts.append("right" if direction["left_right"] > 0 else "left") + if abs(direction["superior_inferior"]) > 10: + parts.append("superior" if direction["superior_inferior"] > 0 else "inferior") + bulk_direction = " and ".join(parts) if parts else "no dominant direction" + + return { + "top_positive": top_positive, + "top_negative": top_negative, + "direction": direction, + "pattern_type": pattern_type, + "bulk_direction": bulk_direction, + "n_significant_positive": n_pos, + "n_significant_negative": n_neg, + } + + def build_llm_context(self, delta: np.ndarray, + path_regions: list[str] | None = None) -> str: + """Build a structured text context for LLM interpretation. + + Args: + delta: (R,) ROI delta vector. + path_regions: optional list of brain region names the probe traversed. + + Returns: + Formatted context string for the LLM prompt. + """ + analysis = self.analyze_flow_pattern(delta) + + lines = [] + lines.append("=== ROI FLOW ANALYSIS ===\n") + + # Pattern overview + lines.append(f"Flow pattern type: {analysis['pattern_type']}") + lines.append(f"Bulk information flow direction: {analysis['bulk_direction']}") + lines.append(f"Significant ROIs with increased activation: " + f"{analysis['n_significant_positive']}") + lines.append(f"Significant ROIs with decreased activation: " + f"{analysis['n_significant_negative']}") + lines.append("") + + # Top receivers (positive delta) + if analysis["top_positive"]: + lines.append("TOP RECEIVING ROIs (activation INCREASED):") + for name, val, center in analysis["top_positive"][:8]: + side = "left" if center[0] < 0 else "right" + depth = "anterior" if center[1] > 0 else "posterior" + lines.append(f" - {name} ({side}, {depth}): delta = +{val:.4f}, " + f"MNI = ({center[0]:.0f}, {center[1]:.0f}, {center[2]:.0f})") + lines.append("") + + # Top donors (negative delta) + if analysis["top_negative"]: + lines.append("TOP DONOR ROIs (activation DECREASED):") + for name, val, center in analysis["top_negative"][:8]: + side = "left" if center[0] < 0 else "right" + depth = "anterior" if center[1] > 0 else "posterior" + lines.append(f" - {name} ({side}, {depth}): delta = {val:.4f}, " + f"MNI = ({center[0]:.0f}, {center[1]:.0f}, {center[2]:.0f})") + lines.append("") + + # Path context + if path_regions: + lines.append("MANIFOLD PATH TRAVERSED THROUGH THESE REGIONS:") + for i, r in enumerate(path_regions, 1): + lines.append(f" {i}. {r}") + lines.append("") + + # Directional summary + d = analysis["direction"] + lines.append("DIRECTIONAL ANALYSIS:") + lines.append(f" Left-Right shift: {d['left_right']:.1f} mm " + f"({'rightward' if d['left_right'] > 0 else 'leftward'})") + lines.append(f" Anterior-Posterior shift: {d['anterior_posterior']:.1f} mm " + f"({'anterior' if d['anterior_posterior'] > 0 else 'posterior'})") + lines.append(f" Superior-Inferior shift: {d['superior_inferior']:.1f} mm " + f"({'superior' if d['superior_inferior'] > 0 else 'inferior'})") + + return "\n".join(lines) + + +# --------------------------------------------------------------------------- +# ROI flow LLM interpreter +# --------------------------------------------------------------------------- + +class ROIFlowLLM: + """Send ROI flow analysis to LLM for interpretation.""" + + def __init__(self, model: str = "gpt-5.4-mini", debug: bool = False): + self.model = model + self.debug = debug + + def interpret_roi_flow(self, context: str) -> str: + """Interpret an ROI flow pattern using the LLM. + + Args: + context: structured text from ROIFlowAnalyzer.build_llm_context() + + Returns: + LLM interpretation text. + """ + from src.region_analyzer import _ensure_ssl + _ensure_ssl() + + from dotenv import load_dotenv + load_dotenv() + + instructions = ( + "You are a neuroscientist interpreting brain state dynamics from a " + "manifold flow simulation. The user traced a path through a learned " + "neural manifold (a dimensionality-reduced representation of resting-state " + "brain dynamics). You are given how each ROI's contribution changed along " + "this path.\n\n" + "IMPORTANT: The delta values do NOT mean regions became more or less " + "'active' in a simple sense. They measure how each ROI's CONTRIBUTION " + "to the overall brain state shifted — some regions contribute more to the " + "new state, some less. A positive delta means the region became a stronger " + "contributor; negative means it became a weaker contributor. This is a " + "transition in the brain's dynamic state.\n\n" + "Analyze what this particular state transition might mean. Do NOT list " + "the individual ROI changes — instead, synthesize them into ONE coherent " + "picture of what cognitive or neural process could underlie this specific " + "shift in brain dynamics. Consider the spatial pattern (which networks " + "gained vs lost contribution), the directionality, and what spontaneous " + "resting-state process would produce this exact transition.\n\n" + "Keep your response SHORT — 2-3 concise paragraphs maximum.\n\n" + "Note: ROI deltas are interpolated from a learned resting-state manifold — " + "interpret transitions as shifts in dynamic brain state, not literal activations." + ) + + question = context + + if self.debug: + import sys + print(f"\n{'='*60}") + print(f"[DEBUG PROMPT] ROIFlowLLM.interpret_roi_flow") + print(f"{'='*60}") + print(f"INSTRUCTIONS:\n{instructions}") + print(f"\nINPUT:\n{question}") + print(f"{'='*60}\n") + sys.stdout.flush() + + try: + from openai import OpenAI + client = OpenAI(timeout=60.0) + + resp = client.responses.create( + model=self.model, + instructions=instructions, + input=question, + reasoning={"effort": "low"}, + max_output_tokens=2500, + ) + + text = (resp.output_text or "").strip() + if text: + return text + + if getattr(resp, "status", None) == "incomplete": + reason = getattr(resp.incomplete_details, "reason", "unknown") + return f"[GPT ERROR] Responses API incomplete: {reason}" + + return "[GPT ERROR] Responses API returned no visible text." + + except Exception as e: + import traceback + traceback.print_exc() + return f"[GPT ERROR] {e}" + + def compare_two_paths(self, context_a: str, context_b: str) -> str: + """Compare two ROI flow patterns using the LLM. + + Args: + context_a: structured text from path A analysis + context_b: structured text from path B analysis + + Returns: + LLM comparison interpretation text. + """ + from src.region_analyzer import _ensure_ssl + _ensure_ssl() + + from dotenv import load_dotenv + load_dotenv() + + instructions = ( + "You are a neuroscientist comparing TWO different brain state " + "transitions observed in a neural manifold flow simulation. " + "Each path represents a different trajectory through the manifold, " + "producing different shifts in ROI contributions.\n\n" + "You are given the ROI flow analysis for Path A and Path B.\n\n" + "Compare them: What is SIMILAR between the two transitions? " + "What is DIFFERENT? Which cognitive or neural processes might " + "explain the divergence? Which path represents a more dramatic " + "state change? Are they transitions within the same network or " + "do they involve fundamentally different networks?\n\n" + "Keep your response SHORT — 2-3 concise paragraphs maximum.\n\n" + "Note: both paths are trajectories through a learned resting-state manifold — " + "compare them as different dynamic regimes, not literal biological events." + ) + + question = ( + "=== PATH A ===\n" + context_a + "\n\n" + "=== PATH B ===\n" + context_b + ) + + if self.debug: + import sys + print(f"\n{'='*60}") + print(f"[DEBUG PROMPT] ROIFlowLLM.compare_two_paths") + print(f"{'='*60}") + print(f"INSTRUCTIONS:\n{instructions}") + print(f"\nINPUT:\n{question}") + print(f"{'='*60}\n") + sys.stdout.flush() + + try: + from openai import OpenAI + client = OpenAI(timeout=90.0) + + resp = client.responses.create( + model=self.model, + instructions=instructions, + input=question, + reasoning={"effort": "low"}, + max_output_tokens=3000, + ) + + text = (resp.output_text or "").strip() + if text: + return text + return "[GPT ERROR] No response text." + + except Exception as e: + import traceback + traceback.print_exc() + return f"[GPT ERROR] {e}" diff --git a/start.sh b/start.sh new file mode 100644 index 0000000000000000000000000000000000000000..a218c5b3e02875169d222d8282eba5b1cff65e47 --- /dev/null +++ b/start.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +export DISPLAY="${DISPLAY:-:99}" +export PORT="${PORT:-7860}" +export VNC_GEOMETRY="${VNC_GEOMETRY:-1440x900x24}" +export MINDVIS_ARGS="${MINDVIS_ARGS:-}" + +cd /app/mindVisualizer + +rm -f "/tmp/.X${DISPLAY#:}-lock" + +Xvfb "${DISPLAY}" -screen 0 "${VNC_GEOMETRY}" -ac +extension GLX +render -noreset \ + >/tmp/xvfb.log 2>&1 & + +sleep 3 + +python /app/mindVisualizer/app.py --host 0.0.0.0 --port "${PORT}" ${MINDVIS_ARGS}