mindvisualizer-live / examples /roi_flow_mode.py
Pixedar's picture
Deploy full MindVisualizer Python runtime
03e863f
Raw
History Blame Contribute Delete
56.4 kB
#!/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()