Pixedar commited on
Commit
ad37493
·
verified ·
1 Parent(s): 6ba0ae8

Deploy MindVisualizer API

Browse files
Dockerfile ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ ENV PYTHONDONTWRITEBYTECODE=1
4
+ ENV PYTHONUNBUFFERED=1
5
+ ENV MINDVIS_REPO=/app/mindVisualizer
6
+
7
+ WORKDIR /app/api
8
+
9
+ COPY requirements.txt .
10
+ RUN pip install --no-cache-dir -r requirements.txt
11
+
12
+ COPY app.py .
13
+ COPY mindVisualizer /app/mindVisualizer
14
+
15
+ EXPOSE 7860
16
+
17
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,10 +1,47 @@
1
  ---
2
- title: Mindvisualizer Api
3
- emoji: 😻
4
- colorFrom: gray
5
- colorTo: pink
6
  sdk: docker
 
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: MindVisualizer API
 
 
 
3
  sdk: docker
4
+ app_port: 7860
5
  pinned: false
6
  ---
7
 
8
+ # MindVisualizer Python API
9
+
10
+ Small FastAPI adapter for the hosted web page.
11
+
12
+ The GitHub Pages frontend renders particles locally. This backend is only for algorithm-sensitive operations:
13
+
14
+ - recomputing probe trajectories with the original Python `TriLinearSampler`
15
+ - mapping a trajectory to ROI vectors with the original `ManifoldToROIKNN`
16
+ - building the original `ROIFlowAnalyzer` context
17
+ - optionally calling the original `ROIFlowLLM` if `OPENAI_API_KEY` is configured
18
+
19
+ ## Cheapest hosting shape
20
+
21
+ Use a free Python container host and keep the service public:
22
+
23
+ 1. Bundle the minimal `mindVisualizer` source/data folder into the Space as `/app/mindVisualizer`.
24
+ 2. Deploy this directory as a Docker/FastAPI service.
25
+ 3. Set `MINDVIS_REPO=/app/mindVisualizer`.
26
+ 4. Set `CORS_ORIGINS=https://pixedar.github.io,http://localhost:3000`.
27
+ 5. In the GitHub Pages build, set `NEXT_PUBLIC_MINDVIS_API_URL=https://your-backend-url`.
28
+
29
+ Do not install the full `mindVisualizer` `pyproject.toml` on a tiny free host; it pulls desktop/VTK dependencies. This adapter uses `PYTHONPATH` and installs only the packages needed for the API path.
30
+
31
+ ## Local run
32
+
33
+ ```bash
34
+ cd backend/mindvis-api
35
+ python -m venv .venv
36
+ .venv\Scripts\activate
37
+ pip install -r requirements.txt
38
+ $env:MINDVIS_REPO="C:\Users\assas\PycharmProjects\mindVisualizer"
39
+ uvicorn app:app --reload --port 7860
40
+ ```
41
+
42
+ Then build/run the web page with:
43
+
44
+ ```bash
45
+ $env:NEXT_PUBLIC_MINDVIS_API_URL="http://localhost:7860"
46
+ pnpm dev
47
+ ```
app.py ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ import sys
4
+ from functools import lru_cache
5
+ from pathlib import Path
6
+ from typing import Literal
7
+
8
+ import numpy as np
9
+ from fastapi import FastAPI, HTTPException
10
+ from fastapi.middleware.cors import CORSMiddleware
11
+ from pydantic import BaseModel, Field
12
+
13
+
14
+ FieldMode = Literal["mean", "gated", "mu1", "mu2"]
15
+
16
+
17
+ class TrajectoryRequest(BaseModel):
18
+ seed: list[float] = Field(default_factory=lambda: [0.5, 0.5, 0.5], min_length=3, max_length=3)
19
+ fieldMode: FieldMode = "gated"
20
+ steps: int = Field(default=220, ge=8, le=1200)
21
+ dt: float = Field(default=0.018, gt=0, le=0.1)
22
+ speedScale: float = Field(default=0.48, gt=0, le=4)
23
+
24
+
25
+ class ExplainRequest(BaseModel):
26
+ trajectory: list[list[float]] = Field(min_length=2)
27
+ fieldMode: FieldMode = "gated"
28
+ callLlm: bool = True
29
+
30
+
31
+ def mindvis_repo() -> Path:
32
+ repo = Path(os.environ.get("MINDVIS_REPO", "/app/mindVisualizer")).resolve()
33
+ if not repo.exists():
34
+ raise RuntimeError(f"MINDVIS_REPO does not exist: {repo}")
35
+ if str(repo) not in sys.path:
36
+ sys.path.insert(0, str(repo))
37
+ return repo
38
+
39
+
40
+ @lru_cache(maxsize=1)
41
+ def services():
42
+ repo = mindvis_repo()
43
+
44
+ from src.field_loader import TriLinearSampler, load_field
45
+ from src.roi_flow import ManifoldToROIKNN, ROIFlowAnalyzer
46
+
47
+ data = repo / "data"
48
+ flow = load_field(data / "roi_flow" / "mdn_universal_raw_grid64_meta.json")
49
+ mean = flow["mean"]
50
+ mu1 = flow["mus"][0]
51
+ mu2 = flow["mus"][1]
52
+ pi = flow["pi"]
53
+ gated = np.where(np.argmax(pi, axis=-1)[..., None] == 0, mu1, mu2).astype(np.float32)
54
+
55
+ samplers = {
56
+ "mean": TriLinearSampler(mean, flow["amin"], flow["amax"]),
57
+ "gated": TriLinearSampler(gated, flow["amin"], flow["amax"]),
58
+ "mu1": TriLinearSampler(mu1, flow["amin"], flow["amax"]),
59
+ "mu2": TriLinearSampler(mu2, flow["amin"], flow["amax"]),
60
+ }
61
+
62
+ embed = np.load(data / "roi_flow" / "probe_embed.npy").astype(np.float32)
63
+ roi = np.load(data / "roi_flow" / "probe_roi.npy").astype(np.float32)
64
+ centers = np.load(data / "roi_flow" / "probe_roi_centers.npy").astype(np.float32)
65
+ structures = json.loads((data / "structures.json").read_text(encoding="utf-8"))
66
+
67
+ names = []
68
+ for idx in range(roi.shape[1]):
69
+ item = structures[idx] if idx < len(structures) else {}
70
+ names.append(item.get("name") or item.get("acronym") or f"ROI {idx + 1}")
71
+
72
+ mapper = ManifoldToROIKNN(embed, roi, k=256)
73
+ analyzer = ROIFlowAnalyzer(names, centers[: len(names)])
74
+
75
+ return {
76
+ "amin": flow["amin"],
77
+ "amax": flow["amax"],
78
+ "span": flow["amax"] - flow["amin"],
79
+ "samplers": samplers,
80
+ "mapper": mapper,
81
+ "analyzer": analyzer,
82
+ }
83
+
84
+
85
+ app = FastAPI(title="MindVisualizer API", version="0.1.0")
86
+
87
+ origins = [origin.strip() for origin in os.environ.get("CORS_ORIGINS", "*").split(",") if origin.strip()]
88
+ app.add_middleware(
89
+ CORSMiddleware,
90
+ allow_origins=origins,
91
+ allow_methods=["GET", "POST", "OPTIONS"],
92
+ allow_headers=["*"],
93
+ )
94
+
95
+
96
+ @app.get("/health")
97
+ def health():
98
+ try:
99
+ svc = services()
100
+ return {"ok": True, "fieldMin": svc["amin"].tolist(), "fieldMax": svc["amax"].tolist()}
101
+ except Exception as exc:
102
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
103
+
104
+
105
+ @app.post("/api/mindvis/trajectory")
106
+ def trajectory(req: TrajectoryRequest):
107
+ svc = services()
108
+ path = integrate_path(req.seed, req.fieldMode, req.steps, req.dt, req.speedScale)
109
+ return {
110
+ "fieldMode": req.fieldMode,
111
+ "trajectory": normalized(path, svc["amin"], svc["span"]).tolist(),
112
+ }
113
+
114
+
115
+ @app.post("/api/mindvis/explain")
116
+ def explain(req: ExplainRequest):
117
+ svc = services()
118
+ traj = np.asarray(req.trajectory, dtype=np.float32)
119
+ if traj.ndim != 2 or traj.shape[1] != 3:
120
+ raise HTTPException(status_code=400, detail="trajectory must be an array of [x,y,z] points")
121
+
122
+ world = denormalized(np.clip(traj, 0.0, 1.0), svc["amin"], svc["span"])
123
+ mapper = svc["mapper"]
124
+ analyzer = svc["analyzer"]
125
+ start_roi = mapper.query(world[0])
126
+ end_roi = mapper.query(world[-1])
127
+ delta = analyzer.compute_delta(start_roi, end_roi)
128
+ context = analyzer.build_llm_context(delta)
129
+
130
+ text = ""
131
+ if req.callLlm and os.environ.get("OPENAI_API_KEY"):
132
+ from src.roi_flow import ROIFlowLLM
133
+
134
+ text = ROIFlowLLM(debug=False).interpret_roi_flow(context)
135
+
136
+ if not text:
137
+ analysis = analyzer.analyze_flow_pattern(delta)
138
+ text = (
139
+ f"Pattern: {analysis['pattern_type']}. "
140
+ f"Bulk direction: {analysis['bulk_direction']}. "
141
+ f"Positive ROIs: {analysis['n_significant_positive']}; "
142
+ f"negative ROIs: {analysis['n_significant_negative']}."
143
+ )
144
+
145
+ return {"text": text, "context": context}
146
+
147
+
148
+ def integrate_path(seed: list[float], field_mode: FieldMode, steps: int, dt: float, speed_scale: float) -> np.ndarray:
149
+ svc = services()
150
+ p = denormalized(np.asarray(seed[:3], dtype=np.float32)[None, :], svc["amin"], svc["span"])
151
+ sampler = svc["samplers"][field_mode]
152
+ path = np.empty((steps, 3), dtype=np.float32)
153
+
154
+ for idx in range(steps):
155
+ path[idx] = p[0]
156
+ v = sampler.sample_vec(p)
157
+ p = p + v * (dt * speed_scale)
158
+ p = ((p - svc["amin"]) % svc["span"]) + svc["amin"]
159
+
160
+ return path
161
+
162
+
163
+ def denormalized(points: np.ndarray, amin: np.ndarray, span: np.ndarray) -> np.ndarray:
164
+ return points.astype(np.float32) * span[None, :] + amin[None, :]
165
+
166
+
167
+ def normalized(points: np.ndarray, amin: np.ndarray, span: np.ndarray) -> np.ndarray:
168
+ return (points.astype(np.float32) - amin[None, :]) / span[None, :]
mindVisualizer/data/roi_flow/mdn_training_all_points.npy ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:bf047435bda457ab7eb4d59940ae8463555bb92cffadaed9d13aad234123887f
3
+ size 223208
mindVisualizer/data/roi_flow/mdn_universal_all_grid64_entropy.bin ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:5ef8fb7dace16f77b06b262e09dbbbd0070c21d5ac71e49b280a4af5e34fee2e
3
+ size 1048576
mindVisualizer/data/roi_flow/mdn_universal_all_grid64_mean_xyz3.bin ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:4157011b84e35e0a861730baa16de6a97b40ca42b5a0001356760d02a4cddaef
3
+ size 3145728
mindVisualizer/data/roi_flow/mdn_universal_all_grid64_mu1_xyz3.bin ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:0e64c8f46eaa1c0d3fa269440687f9ff24b561567feaa7fdbb7f4d1aa259976f
3
+ size 3145728
mindVisualizer/data/roi_flow/mdn_universal_all_grid64_mu2_xyz3.bin ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:d418aa24f897fdd78cee36f35e499d680db00fe987a72cd935ad4e09b2ea6b95
3
+ size 3145728
mindVisualizer/data/roi_flow/mdn_universal_all_grid64_pi.bin ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:1c8cf547e39333a47b43030ac903fd0eec449a12e3dd7be131dafb7caff9d8ea
3
+ size 1048576
mindVisualizer/data/roi_flow/mdn_universal_raw_grid64_meta.json ADDED
@@ -0,0 +1 @@
 
 
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}
mindVisualizer/data/roi_flow/probe_embed.npy ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:baecae7d26b1d76bace68d96f005b0dfc0e737dd83383345e819b669259b5af1
3
+ size 113384
mindVisualizer/data/roi_flow/probe_roi.npy ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:beef9cdff1f1453f9430fc9e2943b6a946203b57fb9a24044a2081bb76a377f1
3
+ size 37752128
mindVisualizer/data/roi_flow/probe_roi_centers.npy ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:8a64ab89787f423cb3d4ac4e953a5fe056c4be9a7b3fe4738be529d14fac7b6b
3
+ size 12128
mindVisualizer/data/structures.json ADDED
@@ -0,0 +1 @@
 
 
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]}]
mindVisualizer/src/__init__.py ADDED
File without changes
mindVisualizer/src/field_loader.py ADDED
@@ -0,0 +1,192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Load MDN vector fields from metadata JSON + binary grid files."""
2
+
3
+ import json
4
+ import re
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ import numpy as np
9
+
10
+
11
+ def load_field(meta_path: Path, grid_bin_path: Optional[Path] = None) -> dict:
12
+ """
13
+ Load MDN vector grids and metadata. Supports arbitrary K mixture components.
14
+ Returns dict with keys: G, amin, amax, mean, mus, pi, ENT, TRAIN, meta.
15
+ """
16
+ meta = json.loads(Path(meta_path).read_text(encoding="utf-8"))
17
+ G = int(meta["grid"])
18
+ amin = np.array(meta["axis_min"], dtype=np.float32)
19
+ amax = np.array(meta["axis_max"], dtype=np.float32)
20
+ files = dict(meta.get("files") or {})
21
+
22
+ def _resolve(p):
23
+ p = Path(p)
24
+ return p if p.exists() else (Path(meta_path).parent / p)
25
+
26
+ def _load_xyz3(fname):
27
+ raw = np.fromfile(_resolve(fname), dtype=np.float32)
28
+ return raw.reshape(G, G, G, 3).astype(np.float32)
29
+
30
+ def _load_scalar_or_channels(fname):
31
+ raw = np.fromfile(_resolve(fname), dtype=np.float32)
32
+ base = G * G * G
33
+ if raw.size == base:
34
+ return raw.reshape(G, G, G).astype(np.float32)
35
+ if raw.size % base == 0:
36
+ K = raw.size // base
37
+ return raw.reshape(G, G, G, K).astype(np.float32)
38
+ raise ValueError(f"{fname}: size {raw.size} incompatible with G^3={base}")
39
+
40
+ # mean field
41
+ if "mean_xyz3" in files:
42
+ V_mean = _load_xyz3(files["mean_xyz3"])
43
+ else:
44
+ if grid_bin_path is None:
45
+ grid_bin_path = Path(meta_path).with_name(
46
+ Path(meta_path).name.replace("_meta.json", ".bin"))
47
+ raw = np.fromfile(grid_bin_path, dtype=np.float32)
48
+ V_mean = raw.reshape(G, G, G, 3).astype(np.float32)
49
+
50
+ # collect mu_k components
51
+ mu_items = []
52
+ for k, v in files.items():
53
+ ks = str(k).lower()
54
+ if "xyz3" in ks and "mu" in ks:
55
+ m = re.search(r'(\d+)(?!.*\d)', ks)
56
+ if m:
57
+ mu_items.append((int(m.group(1)), v))
58
+ mu_items.sort(key=lambda kv: kv[0])
59
+ MUS = [_load_xyz3(fname) for _, fname in mu_items]
60
+ K_mu = len(MUS)
61
+
62
+ # pi (mixture weights)
63
+ PI = None
64
+ if "pi" in files:
65
+ PI = _load_scalar_or_channels(files["pi"])
66
+ if PI.ndim == 3 and K_mu == 2:
67
+ PI = np.stack([PI, 1.0 - PI], axis=-1).astype(np.float32)
68
+
69
+ # entropy
70
+ ENT = None
71
+ if "entropy" in files:
72
+ ENT = _load_scalar_or_channels(files["entropy"])
73
+ if ENT.ndim != 3:
74
+ raise ValueError(f"'entropy' must be (G,G,G); got {ENT.shape}")
75
+
76
+ # training points
77
+ train_pts = None
78
+ tp_key = meta.get("training_points_npy")
79
+ if tp_key:
80
+ p = _resolve(tp_key)
81
+ if p.exists():
82
+ train_pts = np.load(p).astype(np.float32)
83
+
84
+ return {
85
+ "G": G, "amin": amin, "amax": amax,
86
+ "mean": V_mean,
87
+ "mus": MUS,
88
+ "pi": PI,
89
+ "ENT": ENT,
90
+ "TRAIN": train_pts,
91
+ "meta": meta,
92
+ }
93
+
94
+
95
+ class TriLinearSampler:
96
+ """Trilinear interpolation of 3D vector or scalar fields at arbitrary points."""
97
+
98
+ def __init__(self, grid_xyz3, amin, amax):
99
+ self.g = grid_xyz3.astype(np.float32)
100
+ self.G = grid_xyz3.shape[0]
101
+ self.amin = amin.astype(np.float32)
102
+ self.amax = amax.astype(np.float32)
103
+ span = self.amax - self.amin
104
+ self.inv_span = np.where(span > 0, 1.0 / span, 0).astype(np.float32)
105
+
106
+ def _idx(self, P):
107
+ f = (P - self.amin) * self.inv_span * (self.G - 1)
108
+ ix = np.floor(f[:, 0]).astype(np.int32)
109
+ tx = (f[:, 0] - ix).astype(np.float32)
110
+ iy = np.floor(f[:, 1]).astype(np.int32)
111
+ ty = (f[:, 1] - iy).astype(np.float32)
112
+ iz = np.floor(f[:, 2]).astype(np.int32)
113
+ tz = (f[:, 2] - iz).astype(np.float32)
114
+ ix = np.clip(ix, 0, self.G - 2)
115
+ iy = np.clip(iy, 0, self.G - 2)
116
+ iz = np.clip(iz, 0, self.G - 2)
117
+ return ix, iy, iz, tx, ty, tz
118
+
119
+ def sample_vec(self, P):
120
+ ix, iy, iz, tx, ty, tz = self._idx(P)
121
+ g = self.g
122
+ v000 = g[ix, iy, iz]
123
+ v100 = g[ix + 1, iy, iz]
124
+ v010 = g[ix, iy + 1, iz]
125
+ v110 = g[ix + 1, iy + 1, iz]
126
+ v001 = g[ix, iy, iz + 1]
127
+ v101 = g[ix + 1, iy, iz + 1]
128
+ v011 = g[ix, iy + 1, iz + 1]
129
+ v111 = g[ix + 1, iy + 1, iz + 1]
130
+ vx00 = v000 * (1 - tx)[:, None] + v100 * tx[:, None]
131
+ vx10 = v010 * (1 - tx)[:, None] + v110 * tx[:, None]
132
+ vx01 = v001 * (1 - tx)[:, None] + v101 * tx[:, None]
133
+ vx11 = v011 * (1 - tx)[:, None] + v111 * tx[:, None]
134
+ vxy0 = vx00 * (1 - ty)[:, None] + vx10 * ty[:, None]
135
+ vxy1 = vx01 * (1 - ty)[:, None] + vx11 * ty[:, None]
136
+ out = vxy0 * (1 - tz)[:, None] + vxy1 * tz[:, None]
137
+ return out.astype(np.float32)
138
+
139
+ def sample_scalar(self, S, P):
140
+ ix, iy, iz, tx, ty, tz = self._idx(P)
141
+ s000 = S[ix, iy, iz]
142
+ s100 = S[ix + 1, iy, iz]
143
+ s010 = S[ix, iy + 1, iz]
144
+ s110 = S[ix + 1, iy + 1, iz]
145
+ s001 = S[ix, iy, iz + 1]
146
+ s101 = S[ix + 1, iy, iz + 1]
147
+ s011 = S[ix, iy + 1, iz + 1]
148
+ s111 = S[ix + 1, iy + 1, iz + 1]
149
+ sx00 = s000 * (1 - tx) + s100 * tx
150
+ sx10 = s010 * (1 - tx) + s110 * tx
151
+ sx01 = s001 * (1 - tx) + s101 * tx
152
+ sx11 = s011 * (1 - tx) + s111 * tx
153
+ return (sx00 * (1 - ty) + sx10 * ty) * (1 - tz) + (sx01 * (1 - ty) + sx11 * ty) * tz
154
+
155
+
156
+ def load_points_any(path: Path) -> np.ndarray:
157
+ """Load point cloud from .npy, .obj, or .ply file."""
158
+ p = Path(path)
159
+ if p.suffix.lower() == ".npy":
160
+ return np.asarray(np.load(p), np.float32)
161
+ if p.suffix.lower() == ".obj":
162
+ pts = []
163
+ with open(p, "r", encoding="utf-8", errors="ignore") as f:
164
+ for line in f:
165
+ if not line or line[0] != 'v' or (len(line) > 1 and line[1] not in (' ', '\t')):
166
+ continue
167
+ parts = line.strip().split()
168
+ if len(parts) >= 4:
169
+ pts.append([float(parts[1]), float(parts[2]), float(parts[3])])
170
+ return np.asarray(pts, np.float32)
171
+ if p.suffix.lower() == ".ply":
172
+ with open(p, "r", encoding="utf-8", errors="ignore") as f:
173
+ header = []
174
+ while True:
175
+ line = f.readline()
176
+ if not line:
177
+ raise ValueError("Invalid PLY (no end_header)")
178
+ header.append(line.strip())
179
+ if line.strip() == "end_header":
180
+ break
181
+ nverts = 0
182
+ for h in header:
183
+ if h.startswith("element vertex"):
184
+ nverts = int(h.split()[-1])
185
+ break
186
+ pts = []
187
+ for _ in range(nverts):
188
+ parts = f.readline().strip().split()
189
+ if len(parts) >= 3:
190
+ pts.append([float(parts[0]), float(parts[1]), float(parts[2])])
191
+ return np.asarray(pts, np.float32)
192
+ raise ValueError(f"Unsupported point format: {p.suffix}")
mindVisualizer/src/region_analyzer.py ADDED
@@ -0,0 +1,703 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Analyze probe trajectory through brain regions and explain transitions via GPT."""
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+
7
+ import numpy as np
8
+
9
+ # Path to RAG knowledge files (JSON and TXT files with brain region descriptions)
10
+ RAG_KNOWLEDGE_DIR = Path(__file__).resolve().parent.parent / "data" / "rag_knowledge"
11
+
12
+
13
+
14
+ def load_knowledge_from_files(directory: Path | None = None) -> dict[str, str]:
15
+ """Load brain region knowledge from user-supplied JSON and TXT files.
16
+
17
+ JSON files: {"Region Name": "description", ...}
18
+ TXT files: sections separated by '## Region Name' headers.
19
+
20
+ Returns merged dict. Empty dict if directory missing/empty.
21
+ """
22
+ directory = directory or RAG_KNOWLEDGE_DIR
23
+ knowledge: dict[str, str] = {}
24
+ if not directory.is_dir():
25
+ return knowledge
26
+
27
+ # Process files in sorted order for deterministic merging
28
+ for fpath in sorted(directory.iterdir()):
29
+ if fpath.suffix == ".json":
30
+ try:
31
+ data = json.loads(fpath.read_text(encoding="utf-8"))
32
+ if isinstance(data, dict):
33
+ count = 0
34
+ for k, v in data.items():
35
+ if isinstance(k, str) and isinstance(v, str):
36
+ knowledge[k] = v
37
+ count += 1
38
+ if count:
39
+ print(f"[RAG] Loaded {count} regions from {fpath.name}")
40
+ except Exception as e:
41
+ print(f"[RAG] Warning: could not load {fpath.name}: {e}")
42
+
43
+ elif fpath.suffix == ".txt":
44
+ try:
45
+ text = fpath.read_text(encoding="utf-8")
46
+ current_region = None
47
+ current_lines: list[str] = []
48
+ count = 0
49
+ for line in text.splitlines():
50
+ if line.startswith("## "):
51
+ if current_region and current_lines:
52
+ knowledge[current_region] = " ".join(current_lines).strip()
53
+ count += 1
54
+ current_region = line[3:].strip()
55
+ current_lines = []
56
+ elif current_region is not None:
57
+ current_lines.append(line.strip())
58
+ if current_region and current_lines:
59
+ knowledge[current_region] = " ".join(current_lines).strip()
60
+ count += 1
61
+ if count:
62
+ print(f"[RAG] Loaded {count} regions from {fpath.name}")
63
+ except Exception as e:
64
+ print(f"[RAG] Warning: could not load {fpath.name}: {e}")
65
+
66
+ return knowledge
67
+
68
+
69
+ def _call_direct_responses_api(question: str, model: str) -> str:
70
+ """Direct OpenAI Responses API call for no-RAG mode.
71
+
72
+ Avoids the LangChain empty-content failure path seen with GPT-5.4 in the
73
+ background thread.
74
+ """
75
+ _ensure_ssl()
76
+
77
+ from dotenv import load_dotenv
78
+ load_dotenv()
79
+
80
+ from openai import OpenAI
81
+
82
+ client = OpenAI(timeout=60.0)
83
+
84
+ instructions = (
85
+ "You are a neuroscience expert interpreting information flow in a "
86
+ "resting-state brain.\n\n"
87
+ "Do NOT repeat the region list.\n"
88
+ "Do explain:\n"
89
+ "1. what specific information is likely flowing,\n"
90
+ "2. how the signal is transformed across regions,\n"
91
+ "3. what spontaneous resting-state process could produce this path,\n"
92
+ "4. the functional purpose of this exact trajectory.\n"
93
+ "Be concrete, not generic."
94
+ )
95
+
96
+ resp = client.responses.create(
97
+ model=model,
98
+ instructions=instructions,
99
+ input=question,
100
+ # This is not a model downgrade. It just keeps enough budget for the visible answer.
101
+ reasoning={"effort": "low"},
102
+ max_output_tokens=2500,
103
+ # Optional, if your SDK/version accepts it and you want longer answers:
104
+ # text={"verbosity": "high"},
105
+ )
106
+
107
+ text = (resp.output_text or "").strip()
108
+ if text:
109
+ return text
110
+
111
+ if getattr(resp, "status", None) == "incomplete" and getattr(resp, "incomplete_details", None):
112
+ reason = getattr(resp.incomplete_details, "reason", "unknown")
113
+ return f"[GPT ERROR] Responses API incomplete: {reason}"
114
+
115
+ return "[GPT ERROR] Responses API returned no visible text."
116
+
117
+ def _get_mesh_volume(mesh_overlay, key: str) -> float:
118
+ """Estimate mesh volume from bounding box (for nesting detection)."""
119
+ bounds = mesh_overlay.get_mesh_bounds(key)
120
+ if bounds is None:
121
+ return float('inf')
122
+ return float((bounds[1] - bounds[0]) * (bounds[3] - bounds[2]) * (bounds[5] - bounds[4]))
123
+
124
+
125
+ def analyze_probe_path(path_positions: np.ndarray, mesh_overlay,
126
+ sample_every: int = 10,
127
+ field_mags: np.ndarray | None = None,
128
+ entropy_sampler=None,
129
+ entropy_field=None) -> list[dict]:
130
+ """Analyze which brain regions the probe traveled through.
131
+
132
+ Handles nested regions: when the probe is inside multiple overlapping
133
+ regions (e.g., a subregion inside a larger area), the smaller region
134
+ is treated as the primary region and the larger one as context.
135
+
136
+ Args:
137
+ path_positions: (N,3) probe path
138
+ mesh_overlay: FlowMeshOverlay instance
139
+ sample_every: check every N steps
140
+ field_mags: raw field magnitudes (independent of speed scale)
141
+ entropy_sampler: TriLinearSampler for entropy
142
+ entropy_field: entropy grid (G,G,G) numpy array
143
+
144
+ Returns a list of dicts with keys: region_key, region_name, entry_idx,
145
+ exit_idx, relative_position, avg_flow_strength, avg_entropy,
146
+ enclosing_regions.
147
+ """
148
+ if len(path_positions) == 0:
149
+ return []
150
+
151
+ region_keys = mesh_overlay.get_all_region_keys()
152
+ # Pre-compute volumes for nesting detection
153
+ volumes = {k: _get_mesh_volume(mesh_overlay, k) for k in region_keys}
154
+
155
+ transitions = []
156
+ prev_regions = set()
157
+
158
+ for i in range(0, len(path_positions), sample_every):
159
+ pt = path_positions[i]
160
+ in_regions = set()
161
+
162
+ for key in region_keys:
163
+ if mesh_overlay.point_in_mesh(pt, key):
164
+ in_regions.add(key)
165
+
166
+ # Sample entropy at this point
167
+ ent_val = None
168
+ if entropy_sampler is not None and entropy_field is not None:
169
+ try:
170
+ ent_val = float(entropy_sampler.sample_scalar(entropy_field, pt[None, :])[0])
171
+ except Exception:
172
+ pass
173
+
174
+ # Detect entries
175
+ entered = in_regions - prev_regions
176
+ for key in entered:
177
+ center = mesh_overlay.get_mesh_center(key)
178
+ rel_pos = _relative_position(pt, center, mesh_overlay.get_mesh_bounds(key))
179
+
180
+ # Find enclosing (larger) regions at this point
181
+ enclosing = []
182
+ for other_key in in_regions:
183
+ if other_key != key and volumes.get(other_key, 0) > volumes.get(key, 0):
184
+ enclosing.append(mesh_overlay.get_region_name(other_key))
185
+
186
+ # Flow strength (independent of speed scale)
187
+ flow_str = None
188
+ if field_mags is not None and i < len(field_mags):
189
+ flow_str = float(field_mags[i])
190
+
191
+ # Hemisphere info (if mesh_overlay supports it)
192
+ hemisphere = None
193
+ if hasattr(mesh_overlay, 'get_hemisphere_label'):
194
+ try:
195
+ hemisphere = mesh_overlay.get_hemisphere_label(key, pt)
196
+ except Exception:
197
+ pass
198
+
199
+ transition_dict = {
200
+ "region_key": key,
201
+ "region_name": mesh_overlay.get_region_name(key),
202
+ "entry_idx": i,
203
+ "exit_idx": None,
204
+ "relative_position": rel_pos,
205
+ "entry_point": pt.copy(),
206
+ "enclosing_regions": enclosing,
207
+ "avg_flow_strength": flow_str,
208
+ "avg_entropy": ent_val,
209
+ "_flow_samples": [flow_str] if flow_str is not None else [],
210
+ "_ent_samples": [ent_val] if ent_val is not None else [],
211
+ }
212
+ if hemisphere is not None:
213
+ transition_dict["hemisphere"] = hemisphere
214
+
215
+ # Extra parcellation subregion info
216
+ if hasattr(mesh_overlay, '_extra') and mesh_overlay._extra is not None:
217
+ sub = mesh_overlay._extra.get_region_at_point(pt)
218
+ if sub is not None:
219
+ transition_dict["subregion_name"] = sub["name"]
220
+ transition_dict["subregion_volume"] = sub["volume_mm3"]
221
+
222
+ transitions.append(transition_dict)
223
+
224
+ # Accumulate samples for active transitions
225
+ for t in transitions:
226
+ if t["exit_idx"] is None:
227
+ if field_mags is not None and i < len(field_mags):
228
+ t["_flow_samples"].append(float(field_mags[i]))
229
+ if ent_val is not None:
230
+ t["_ent_samples"].append(ent_val)
231
+
232
+ # Detect exits
233
+ exited = prev_regions - in_regions
234
+ for key in exited:
235
+ for t in reversed(transitions):
236
+ if t["region_key"] == key and t["exit_idx"] is None:
237
+ t["exit_idx"] = i
238
+ if t["_flow_samples"]:
239
+ t["avg_flow_strength"] = float(np.mean(t["_flow_samples"]))
240
+ if t["_ent_samples"]:
241
+ t["avg_entropy"] = float(np.mean(t["_ent_samples"]))
242
+ break
243
+
244
+ prev_regions = in_regions
245
+
246
+ # Close any still-open regions
247
+ for t in transitions:
248
+ if t["exit_idx"] is None:
249
+ t["exit_idx"] = len(path_positions) - 1
250
+ if t["_flow_samples"]:
251
+ t["avg_flow_strength"] = float(np.mean(t["_flow_samples"]))
252
+ if t["_ent_samples"]:
253
+ t["avg_entropy"] = float(np.mean(t["_ent_samples"]))
254
+
255
+ # Pre-compute sampled path points for nearby detection / enrichment
256
+ sampled_pts = path_positions[::sample_every]
257
+
258
+ # --- Enrich each transition with detailed interaction context ---
259
+ for t in transitions:
260
+ key = t["region_key"]
261
+ entry_i = t["entry_idx"]
262
+ exit_i = t["exit_idx"] if t["exit_idx"] is not None else len(path_positions) - 1
263
+
264
+ center = mesh_overlay.get_mesh_center(key)
265
+ bounds = mesh_overlay.get_mesh_bounds(key)
266
+
267
+ # Collect all path points inside this region's span
268
+ inside_pts = path_positions[entry_i:exit_i + 1:sample_every]
269
+
270
+ # 1. Interaction type: how deeply the probe penetrated
271
+ if center is not None and bounds is not None and len(inside_pts) > 0:
272
+ extent = np.array([bounds[1]-bounds[0], bounds[3]-bounds[2],
273
+ bounds[5]-bounds[4]])
274
+ extent = np.maximum(extent, 1e-6)
275
+ char_size = float(np.mean(extent)) # characteristic region size
276
+
277
+ # Distance from each inside point to region center
278
+ dists_to_center = np.linalg.norm(inside_pts - center, axis=1)
279
+ min_dist = float(dists_to_center.min())
280
+ mean_dist = float(dists_to_center.mean())
281
+ # Normalized penetration depth (0 = at center, 1 = at edge)
282
+ penetration = 1.0 - min(min_dist / (char_size * 0.5), 1.0)
283
+
284
+ if penetration > 0.6:
285
+ interaction = "passed through center"
286
+ elif penetration > 0.3:
287
+ interaction = "traversed mid-region"
288
+ elif len(inside_pts) <= 2:
289
+ interaction = "briefly touched surface"
290
+ else:
291
+ interaction = "passed through periphery"
292
+ t["interaction_type"] = interaction
293
+ t["penetration_depth"] = round(penetration, 2)
294
+ else:
295
+ t["interaction_type"] = "traversed"
296
+ t["penetration_depth"] = None
297
+
298
+ # 2. Exit position (relative to region)
299
+ if exit_i < len(path_positions) and center is not None and bounds is not None:
300
+ exit_pt = path_positions[min(exit_i, len(path_positions) - 1)]
301
+ t["exit_position"] = _relative_position(exit_pt, center, bounds)
302
+ else:
303
+ t["exit_position"] = "unknown"
304
+
305
+ # 3. Traversal direction through the region
306
+ if len(inside_pts) >= 2:
307
+ direction_vec = inside_pts[-1] - inside_pts[0]
308
+ norm = np.linalg.norm(direction_vec)
309
+ if norm > 1e-6:
310
+ d = direction_vec / norm
311
+ dir_parts = []
312
+ if abs(d[2]) > 0.3:
313
+ dir_parts.append("upward" if d[2] > 0 else "downward")
314
+ if abs(d[0]) > 0.3:
315
+ dir_parts.append("rightward" if d[0] > 0 else "leftward")
316
+ if abs(d[1]) > 0.3:
317
+ dir_parts.append("anteriorly" if d[1] > 0 else "posteriorly")
318
+ t["traversal_direction"] = " and ".join(dir_parts) if dir_parts else "through"
319
+ else:
320
+ t["traversal_direction"] = "stationary"
321
+ else:
322
+ t["traversal_direction"] = "brief"
323
+
324
+ # --- Detect nearby regions (probe flowed close but never entered) ---
325
+ entered_keys = {t["region_key"] for t in transitions}
326
+ NEARBY_THRESHOLD_MM = 5.0 # max distance to consider "nearby"
327
+ nearby_transitions = []
328
+
329
+ for key in region_keys:
330
+ if key in entered_keys:
331
+ continue
332
+ center = mesh_overlay.get_mesh_center(key)
333
+ bounds = mesh_overlay.get_mesh_bounds(key)
334
+ if center is None or bounds is None:
335
+ continue
336
+ extent = np.array([bounds[1]-bounds[0], bounds[3]-bounds[2],
337
+ bounds[5]-bounds[4]])
338
+ extent = np.maximum(extent, 1e-6)
339
+ char_size = float(np.mean(extent))
340
+
341
+ # Check minimum distance from any sampled path point to region center
342
+ dists = np.linalg.norm(sampled_pts - center, axis=1)
343
+ min_dist = float(dists.min())
344
+ # "nearby" = within threshold but also within the region's own characteristic size
345
+ effective_radius = max(char_size * 0.5, NEARBY_THRESHOLD_MM)
346
+ if min_dist < effective_radius:
347
+ closest_idx = int(dists.argmin()) * sample_every
348
+ name = mesh_overlay.get_region_name(key)
349
+ hemisphere = None
350
+ if hasattr(mesh_overlay, 'get_hemisphere_label'):
351
+ try:
352
+ hemisphere = mesh_overlay.get_hemisphere_label(
353
+ key, sampled_pts[int(dists.argmin())])
354
+ except Exception:
355
+ pass
356
+ nearby_t = {
357
+ "region_key": key,
358
+ "region_name": name,
359
+ "entry_idx": closest_idx,
360
+ "exit_idx": closest_idx,
361
+ "relative_position": _relative_position(
362
+ sampled_pts[int(dists.argmin())], center, bounds),
363
+ "entry_point": sampled_pts[int(dists.argmin())].copy(),
364
+ "enclosing_regions": [],
365
+ "avg_flow_strength": None,
366
+ "avg_entropy": None,
367
+ "interaction_type": f"nearby (did not enter, ~{min_dist:.1f}mm away)",
368
+ "penetration_depth": 0.0,
369
+ "exit_position": "n/a",
370
+ "traversal_direction": "n/a",
371
+ }
372
+ if hemisphere is not None:
373
+ nearby_t["hemisphere"] = hemisphere
374
+ nearby_transitions.append(nearby_t)
375
+
376
+ transitions.extend(nearby_transitions)
377
+
378
+ # Clean up internal fields
379
+ for t in transitions:
380
+ t.pop("_flow_samples", None)
381
+ t.pop("_ent_samples", None)
382
+
383
+ # Sort by volume (smaller = more specific) so primary regions come first at each transition
384
+ transitions.sort(key=lambda t: (t["entry_idx"], volumes.get(t["region_key"], float('inf'))))
385
+
386
+ return transitions
387
+
388
+
389
+ def _relative_position(point: np.ndarray, center: np.ndarray | None,
390
+ bounds: np.ndarray | None) -> str:
391
+ """Describe position relative to region center (e.g., 'upper-left')."""
392
+ if center is None or bounds is None:
393
+ return "unknown"
394
+
395
+ diff = point - center
396
+ extent = np.array([
397
+ bounds[1] - bounds[0],
398
+ bounds[3] - bounds[2],
399
+ bounds[5] - bounds[4],
400
+ ])
401
+ extent = np.maximum(extent, 1e-6)
402
+ rel = diff / (extent * 0.5) # normalized to [-1, 1]
403
+
404
+ parts = []
405
+ if abs(rel[2]) > 0.3:
406
+ parts.append("upper" if rel[2] > 0 else "lower")
407
+ if abs(rel[0]) > 0.3:
408
+ parts.append("right" if rel[0] > 0 else "left")
409
+ if abs(rel[1]) > 0.3:
410
+ parts.append("anterior" if rel[1] > 0 else "posterior")
411
+
412
+ if not parts:
413
+ return "center"
414
+ return "-".join(parts)
415
+
416
+
417
+ def format_transitions_text(transitions: list[dict],
418
+ include_branches: list[dict] | None = None) -> str:
419
+ """Format transitions into a human-readable description for GPT.
420
+
421
+ Handles nested regions: smaller (more specific) regions are primary,
422
+ with larger enclosing regions noted as context.
423
+ """
424
+ if not transitions:
425
+ return "The probe did not pass through any identified brain regions."
426
+
427
+ lines = ["The probe traveled through the following brain regions in order:\n"]
428
+ for i, t in enumerate(transitions, 1):
429
+ duration = (t["exit_idx"] or 0) - (t["entry_idx"] or 0)
430
+
431
+ # Include hemisphere in region name if available
432
+ region_label = t['region_name']
433
+ hemisphere = t.get("hemisphere")
434
+ if hemisphere:
435
+ region_label = f"{region_label} ({hemisphere})"
436
+
437
+ extras = []
438
+ if t.get("avg_flow_strength") is not None:
439
+ extras.append(f"flow_strength={t['avg_flow_strength']:.4f}")
440
+ if t.get("avg_entropy") is not None:
441
+ extras.append(f"model_entropy={t['avg_entropy']:.3f} "
442
+ f"({'high uncertainty' if t['avg_entropy'] > 1.5 else 'confident'})")
443
+ extras_str = f", {', '.join(extras)}" if extras else ""
444
+
445
+ enclosing = t.get("enclosing_regions", [])
446
+ ctx_str = ""
447
+ if enclosing:
448
+ ctx_str = f" [within larger region: {', '.join(enclosing)}]"
449
+
450
+ # Extra parcellation subregion info
451
+ subregion = t.get("subregion_name")
452
+ sub_str = ""
453
+ if subregion:
454
+ sub_str = f", more specifically in subregion: {subregion}"
455
+
456
+ # Interaction detail from enrichment
457
+ interaction = t.get("interaction_type", "traversed")
458
+ is_nearby = "nearby" in interaction
459
+
460
+ if is_nearby:
461
+ # Nearby region: compact format, no penetration/direction/exit noise
462
+ entry_pos = t.get("relative_position", "unknown")
463
+ pos_str = f" near {entry_pos} section" if entry_pos and entry_pos != "unknown" else ""
464
+ lines.append(
465
+ f"{i}. {region_label} — {interaction}{pos_str}{ctx_str}{sub_str}"
466
+ )
467
+ else:
468
+ penetration = t.get("penetration_depth")
469
+ direction = t.get("traversal_direction", "")
470
+ exit_pos = t.get("exit_position", "")
471
+
472
+ # Build detailed description
473
+ detail_parts = [f"{interaction}"]
474
+ if penetration is not None:
475
+ detail_parts.append(f"penetration depth {penetration:.0%}")
476
+ if direction and direction not in ("brief", "stationary", "through", "n/a"):
477
+ detail_parts.append(f"moving {direction}")
478
+ detail_str = ", ".join(detail_parts)
479
+
480
+ entry_pos = t.get("relative_position", "unknown")
481
+ exit_str = f", exited from {exit_pos}" if exit_pos and exit_pos not in ("unknown", "n/a") else ""
482
+
483
+ lines.append(
484
+ f"{i}. {region_label} — {detail_str}. "
485
+ f"Entered at {entry_pos}{exit_str}, "
486
+ f"~{duration} steps inside{extras_str}{ctx_str}{sub_str}"
487
+ )
488
+
489
+ if include_branches:
490
+ lines.append("\nBranching events (alternative flow paths detected):")
491
+ for b in include_branches:
492
+ lines.append(f" - Branch at step ~{b.get('entry_idx', '?')}: "
493
+ f"flow split toward {b.get('region_name', 'unknown')}")
494
+
495
+ return "\n".join(lines)
496
+
497
+
498
+ def build_rag_documents():
499
+ """Build LangChain documents from the RAG knowledge base.
500
+
501
+ Loads all JSON and TXT files from data/rag_knowledge/.
502
+ If no files are found, the RAG chain will have no context and the
503
+ LLM will rely on its own training knowledge.
504
+ """
505
+ from langchain_core.documents import Document
506
+
507
+ knowledge = load_knowledge_from_files()
508
+ if not knowledge:
509
+ print("[RAG] Warning: no knowledge files found in data/rag_knowledge/. "
510
+ "LLM will use its own training knowledge only.")
511
+
512
+ docs = []
513
+ for name, desc in knowledge.items():
514
+ docs.append(Document(
515
+ page_content=f"Brain Region: {name}\nFunction: {desc}",
516
+ metadata={"region_name": name}
517
+ ))
518
+ return docs
519
+
520
+
521
+ def _ensure_ssl():
522
+ """Fix SSL cert path for environments where Git overrides it."""
523
+ try:
524
+ import certifi
525
+ cert_file = certifi.where()
526
+ # Force override if current path is missing (Git installer on Windows
527
+ # often sets SSL_CERT_FILE to a non-existent path)
528
+ cur = os.environ.get("SSL_CERT_FILE", "")
529
+ if not cur or not os.path.isfile(cur):
530
+ os.environ["SSL_CERT_FILE"] = cert_file
531
+ cur2 = os.environ.get("REQUESTS_CA_BUNDLE", "")
532
+ if not cur2 or not os.path.isfile(cur2):
533
+ os.environ["REQUESTS_CA_BUNDLE"] = cert_file
534
+ except ImportError:
535
+ pass
536
+
537
+
538
+ def create_rag_chain(model: str = "gpt-5.4-mini"):
539
+ """Create a LangChain RAG chain with region knowledge."""
540
+ _ensure_ssl()
541
+
542
+ from dotenv import load_dotenv
543
+ load_dotenv()
544
+
545
+ from langchain_openai import ChatOpenAI, OpenAIEmbeddings
546
+ from langchain_community.vectorstores import FAISS
547
+ from langchain_core.prompts import ChatPromptTemplate
548
+ from langchain_core.runnables import RunnablePassthrough
549
+ from langchain_core.output_parsers import StrOutputParser
550
+
551
+ docs = build_rag_documents()
552
+
553
+ embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
554
+ vectorstore = FAISS.from_documents(docs, embeddings)
555
+ retriever = vectorstore.as_retriever(search_kwargs={"k": 10})
556
+
557
+ prompt = ChatPromptTemplate.from_template(
558
+ """You are a neuroscience expert interpreting information flow in a resting-state brain.
559
+
560
+ Reference knowledge about relevant brain regions:
561
+ {context}
562
+
563
+ {question}
564
+
565
+ CRITICAL INSTRUCTIONS — read carefully before answering:
566
+ 1. Do NOT list which regions the probe passed through — the user already knows that.
567
+ 2. Do NOT give a generic answer like "this flow is associated with the default mode network" — that is too shallow.
568
+ 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?
569
+ 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?
570
+ 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.
571
+ 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).
572
+ 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."
573
+
574
+ Note: the flow direction reflects rDCM effective connectivity (who influences whom), not neural signal timing. Particle speed is a visualization scale, not biological latency.
575
+ """
576
+ )
577
+
578
+ llm = ChatOpenAI(model=model, temperature=0.3, max_tokens=1500)
579
+
580
+ def format_docs(docs):
581
+ return "\n\n".join(doc.page_content for doc in docs)
582
+
583
+ chain = (
584
+ {"context": retriever | format_docs, "question": RunnablePassthrough()}
585
+ | prompt
586
+ | llm
587
+ | StrOutputParser()
588
+ )
589
+
590
+ return chain
591
+
592
+
593
+ def create_direct_chain(model: str = "gpt-5.4-mini"):
594
+ """Create a direct GPT chain without RAG (uses only GPT's own knowledge)."""
595
+ _ensure_ssl()
596
+
597
+ from dotenv import load_dotenv
598
+ load_dotenv()
599
+
600
+ from langchain_openai import ChatOpenAI
601
+ from langchain_core.prompts import ChatPromptTemplate
602
+ from langchain_core.runnables import RunnablePassthrough
603
+ from langchain_core.output_parsers import StrOutputParser
604
+
605
+ prompt = ChatPromptTemplate.from_template(
606
+ """You are a neuroscience expert interpreting information flow in a resting-state brain.
607
+
608
+ Using your deep knowledge of neuroanatomy, functional connectivity, and resting-state \
609
+ networks, answer the following question:
610
+
611
+ {question}
612
+
613
+ CRITICAL INSTRUCTIONS — read carefully before answering:
614
+ 1. Do NOT list which regions the probe passed through — the user already knows that.
615
+ 2. Do NOT give a generic answer like "this flow is associated with the default mode network" — that is too shallow.
616
+ 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?
617
+ 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?
618
+ 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.
619
+ 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).
620
+ 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."
621
+
622
+ Note: the flow direction reflects rDCM effective connectivity (who influences whom), not neural signal timing. Particle speed is a visualization scale, not biological latency.
623
+ """
624
+ )
625
+
626
+ llm = ChatOpenAI(model=model, temperature=0.3, max_tokens=1500)
627
+
628
+ chain = (
629
+ {"question": RunnablePassthrough()}
630
+ | prompt
631
+ | llm
632
+ | StrOutputParser()
633
+ )
634
+
635
+ return chain
636
+
637
+
638
+ _rag_chain = None
639
+ _direct_chain = None
640
+ _cached_model = None
641
+
642
+
643
+ def analyze_with_gpt(transitions: list[dict], use_rag: bool = True,
644
+ model: str = "gpt-5.4-mini",
645
+ debug: bool = False) -> str:
646
+ global _rag_chain, _direct_chain, _cached_model
647
+
648
+ if not transitions:
649
+ return "No brain regions were traversed. Place the probe inside the brain volume."
650
+
651
+ description = format_transitions_text(transitions)
652
+ question = (
653
+ f"A probe was placed in a resting-state brain and followed the intrinsic information "
654
+ f"flow field through these regions:\n\n{description}\n\n"
655
+ f"Analyze this SPECIFIC flow pathway in depth:\n"
656
+ f"1. What concrete information is likely being carried along this path?\n"
657
+ f"2. How does the information CHANGE and get TRANSFORMED as it passes through each region?\n"
658
+ f"3. What spontaneous resting-state cognitive process would produce this EXACT flow?\n"
659
+ f"4. What is the functional PURPOSE of information flowing in this exact direction and order?\n"
660
+ f"Do NOT repeat the region list or give a generic network label. Give deep, specific insight.\n"
661
+ f"Keep your response SHORT and focused — 2-3 concise paragraphs maximum."
662
+ )
663
+
664
+ if debug:
665
+ import sys
666
+ print(f"\n{'='*60}")
667
+ print(f"[DEBUG PROMPT] analyze_with_gpt (use_rag={use_rag}, model={model})")
668
+ print(f"{'='*60}")
669
+ print(question)
670
+ print(f"{'='*60}\n")
671
+ sys.stdout.flush()
672
+
673
+ # IMPORTANT: bypass LangChain entirely for no-RAG mode
674
+ if not use_rag:
675
+ try:
676
+ print(f"[GPT] Using Responses API directly (model={model}, no RAG)...")
677
+ return _call_direct_responses_api(question, model)
678
+ except Exception as e:
679
+ import traceback
680
+ traceback.print_exc()
681
+ return f"[GPT ERROR] Direct Responses API failed: {e}"
682
+
683
+ # keep existing RAG path for now
684
+ if _cached_model != model:
685
+ _rag_chain = None
686
+ _direct_chain = None
687
+ _cached_model = model
688
+
689
+ if _rag_chain is None:
690
+ print(f"[GPT] Initializing RAG chain (model={model})...")
691
+ try:
692
+ _rag_chain = create_rag_chain(model=model)
693
+ except Exception as e:
694
+ return f"[GPT ERROR] Failed to initialize RAG: {e}"
695
+
696
+ try:
697
+ result = _rag_chain.invoke(question)
698
+ text = result if isinstance(result, str) else str(result)
699
+ return text.strip() if text and text.strip() else "[GPT ERROR] Empty RAG response."
700
+ except Exception as e:
701
+ import traceback
702
+ traceback.print_exc()
703
+ return f"[GPT ERROR] {e}"
mindVisualizer/src/roi_flow.py ADDED
@@ -0,0 +1,403 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ROI Flow mode: manifold-to-ROI mapping and LLM interpretation.
2
+
3
+ Maps probe positions in a learned neural manifold to ROI activation vectors
4
+ using k-nearest-neighbor interpolation, then analyzes how the ROI pattern
5
+ changes along the probe path.
6
+
7
+ This module is used by examples/roi_flow_mode.py for the dual-window
8
+ manifold + ROI visualization.
9
+ """
10
+
11
+ import os
12
+ from pathlib import Path
13
+
14
+ import numpy as np
15
+ from scipy.spatial import cKDTree
16
+
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # Manifold-to-ROI mapper
20
+ # ---------------------------------------------------------------------------
21
+
22
+ class ManifoldToROIKNN:
23
+ """Map manifold positions to ROI activation vectors via kNN + Gaussian weighting.
24
+
25
+ Given a library of (embedding_point, roi_vector) pairs, interpolates
26
+ the ROI vector at any manifold position using Gaussian-weighted kNN.
27
+ """
28
+
29
+ def __init__(self, embed_points: np.ndarray, roi_vectors: np.ndarray,
30
+ k: int = 256, sigma: float = 0.0):
31
+ """
32
+ Args:
33
+ embed_points: (N, D) embedding coordinates (typically D=3).
34
+ roi_vectors: (N, R) ROI activation vectors.
35
+ k: number of nearest neighbors for interpolation.
36
+ sigma: Gaussian bandwidth. If 0, auto-computed from median kNN distance.
37
+ """
38
+ assert embed_points.shape[0] == roi_vectors.shape[0], \
39
+ f"embed ({embed_points.shape[0]}) and roi ({roi_vectors.shape[0]}) must match"
40
+ self.X = embed_points.astype(np.float32)
41
+ self.Y = roi_vectors.astype(np.float32)
42
+ self.k = int(max(8, k))
43
+ self.sigma = float(max(0.0, sigma))
44
+ self.n_rois = self.Y.shape[1]
45
+ self.tree = cKDTree(self.X)
46
+
47
+ def query(self, point: np.ndarray) -> np.ndarray:
48
+ """Interpolate ROI vector at a manifold position.
49
+
50
+ Args:
51
+ point: (D,) or (1,D) position in manifold space.
52
+
53
+ Returns:
54
+ (R,) ROI activation vector.
55
+ """
56
+ p = point.reshape(1, -1).astype(np.float32)
57
+ k_actual = min(self.k, len(self.X))
58
+ d, idx = self.tree.query(p, k=k_actual)
59
+ d = np.asarray(d).ravel().astype(np.float32)
60
+ idx = np.asarray(idx).ravel()
61
+
62
+ if len(idx) == 0:
63
+ return np.zeros(self.n_rois, dtype=np.float32)
64
+
65
+ sig = self.sigma if self.sigma > 0 else float(np.median(d) + 1e-9)
66
+ w = np.exp(-(d ** 2) / (2.0 * sig ** 2)).astype(np.float64)
67
+ sw = float(np.sum(w)) + 1e-12
68
+ mu = (w[:, None] * self.Y[idx]).sum(axis=0) / sw
69
+ return mu.astype(np.float32)
70
+
71
+
72
+ # ---------------------------------------------------------------------------
73
+ # ROI flow analyzer
74
+ # ---------------------------------------------------------------------------
75
+
76
+ class ROIFlowAnalyzer:
77
+ """Analyze ROI delta vectors to extract flow patterns for LLM interpretation."""
78
+
79
+ def __init__(self, roi_names: list[str], roi_centers: np.ndarray):
80
+ """
81
+ Args:
82
+ roi_names: (R,) human-readable ROI names.
83
+ roi_centers: (R, 3) MNI coordinates of ROI centers.
84
+ """
85
+ self.roi_names = roi_names
86
+ self.roi_centers = roi_centers.astype(np.float32)
87
+ self.n_rois = len(roi_names)
88
+
89
+ def compute_delta(self, start_roi: np.ndarray, end_roi: np.ndarray) -> np.ndarray:
90
+ """Compute change in ROI activation from start to end of path."""
91
+ return (end_roi - start_roi).astype(np.float32)
92
+
93
+ def analyze_flow_pattern(self, delta: np.ndarray) -> dict:
94
+ """Extract structured information about the ROI flow pattern.
95
+
96
+ Returns dict with:
97
+ top_positive: list of (name, delta_val, center) for top increased ROIs
98
+ top_negative: list of (name, delta_val, center) for top decreased ROIs
99
+ direction: dict with anterior_posterior, left_right, superior_inferior scores
100
+ pattern_type: "one_to_many", "many_to_one", "distributed", "bilateral_split"
101
+ bulk_direction: human-readable description of dominant flow direction
102
+ """
103
+ abs_delta = np.abs(delta)
104
+ threshold = np.percentile(abs_delta, 85)
105
+
106
+ # Top changed ROIs
107
+ significant = abs_delta > threshold
108
+ pos_mask = (delta > 0) & significant
109
+ neg_mask = (delta < 0) & significant
110
+
111
+ pos_indices = np.where(pos_mask)[0]
112
+ neg_indices = np.where(neg_mask)[0]
113
+
114
+ # Sort by magnitude
115
+ pos_sorted = pos_indices[np.argsort(-delta[pos_indices])]
116
+ neg_sorted = neg_indices[np.argsort(delta[neg_indices])]
117
+
118
+ top_positive = [(self.roi_names[i], float(delta[i]),
119
+ self.roi_centers[i].tolist()) for i in pos_sorted[:10]]
120
+ top_negative = [(self.roi_names[i], float(delta[i]),
121
+ self.roi_centers[i].tolist()) for i in neg_sorted[:10]]
122
+
123
+ # Compute directional bias using weighted centroids
124
+ pos_weighted_center = np.zeros(3)
125
+ neg_weighted_center = np.zeros(3)
126
+ if len(pos_indices) > 0:
127
+ w = delta[pos_indices]
128
+ pos_weighted_center = np.average(self.roi_centers[pos_indices], axis=0,
129
+ weights=w)
130
+ if len(neg_indices) > 0:
131
+ w = np.abs(delta[neg_indices])
132
+ neg_weighted_center = np.average(self.roi_centers[neg_indices], axis=0,
133
+ weights=w)
134
+
135
+ # Direction: from negative (source) to positive (target) centroids
136
+ flow_vec = pos_weighted_center - neg_weighted_center
137
+ direction = {
138
+ "left_right": float(flow_vec[0]), # +X = right
139
+ "anterior_posterior": float(flow_vec[1]), # +Y = anterior
140
+ "superior_inferior": float(flow_vec[2]), # +Z = superior
141
+ }
142
+
143
+ # Pattern type
144
+ n_pos = len(pos_indices)
145
+ n_neg = len(neg_indices)
146
+ if n_neg <= 3 and n_pos > 8:
147
+ pattern_type = "one_to_many"
148
+ elif n_pos <= 3 and n_neg > 8:
149
+ pattern_type = "many_to_one"
150
+ elif n_pos > 0 and n_neg > 0:
151
+ # Check bilateral split
152
+ pos_x = self.roi_centers[pos_indices, 0]
153
+ neg_x = self.roi_centers[neg_indices, 0]
154
+ pos_mean_x = float(np.mean(pos_x))
155
+ neg_mean_x = float(np.mean(neg_x))
156
+ if abs(pos_mean_x - neg_mean_x) > 20: # significant L/R separation
157
+ pattern_type = "bilateral_split"
158
+ else:
159
+ pattern_type = "distributed"
160
+ else:
161
+ pattern_type = "distributed"
162
+
163
+ # Human-readable bulk direction
164
+ parts = []
165
+ if abs(direction["anterior_posterior"]) > 10:
166
+ parts.append("anterior" if direction["anterior_posterior"] > 0 else "posterior")
167
+ if abs(direction["left_right"]) > 10:
168
+ parts.append("right" if direction["left_right"] > 0 else "left")
169
+ if abs(direction["superior_inferior"]) > 10:
170
+ parts.append("superior" if direction["superior_inferior"] > 0 else "inferior")
171
+ bulk_direction = " and ".join(parts) if parts else "no dominant direction"
172
+
173
+ return {
174
+ "top_positive": top_positive,
175
+ "top_negative": top_negative,
176
+ "direction": direction,
177
+ "pattern_type": pattern_type,
178
+ "bulk_direction": bulk_direction,
179
+ "n_significant_positive": n_pos,
180
+ "n_significant_negative": n_neg,
181
+ }
182
+
183
+ def build_llm_context(self, delta: np.ndarray,
184
+ path_regions: list[str] | None = None) -> str:
185
+ """Build a structured text context for LLM interpretation.
186
+
187
+ Args:
188
+ delta: (R,) ROI delta vector.
189
+ path_regions: optional list of brain region names the probe traversed.
190
+
191
+ Returns:
192
+ Formatted context string for the LLM prompt.
193
+ """
194
+ analysis = self.analyze_flow_pattern(delta)
195
+
196
+ lines = []
197
+ lines.append("=== ROI FLOW ANALYSIS ===\n")
198
+
199
+ # Pattern overview
200
+ lines.append(f"Flow pattern type: {analysis['pattern_type']}")
201
+ lines.append(f"Bulk information flow direction: {analysis['bulk_direction']}")
202
+ lines.append(f"Significant ROIs with increased activation: "
203
+ f"{analysis['n_significant_positive']}")
204
+ lines.append(f"Significant ROIs with decreased activation: "
205
+ f"{analysis['n_significant_negative']}")
206
+ lines.append("")
207
+
208
+ # Top receivers (positive delta)
209
+ if analysis["top_positive"]:
210
+ lines.append("TOP RECEIVING ROIs (activation INCREASED):")
211
+ for name, val, center in analysis["top_positive"][:8]:
212
+ side = "left" if center[0] < 0 else "right"
213
+ depth = "anterior" if center[1] > 0 else "posterior"
214
+ lines.append(f" - {name} ({side}, {depth}): delta = +{val:.4f}, "
215
+ f"MNI = ({center[0]:.0f}, {center[1]:.0f}, {center[2]:.0f})")
216
+ lines.append("")
217
+
218
+ # Top donors (negative delta)
219
+ if analysis["top_negative"]:
220
+ lines.append("TOP DONOR ROIs (activation DECREASED):")
221
+ for name, val, center in analysis["top_negative"][:8]:
222
+ side = "left" if center[0] < 0 else "right"
223
+ depth = "anterior" if center[1] > 0 else "posterior"
224
+ lines.append(f" - {name} ({side}, {depth}): delta = {val:.4f}, "
225
+ f"MNI = ({center[0]:.0f}, {center[1]:.0f}, {center[2]:.0f})")
226
+ lines.append("")
227
+
228
+ # Path context
229
+ if path_regions:
230
+ lines.append("MANIFOLD PATH TRAVERSED THROUGH THESE REGIONS:")
231
+ for i, r in enumerate(path_regions, 1):
232
+ lines.append(f" {i}. {r}")
233
+ lines.append("")
234
+
235
+ # Directional summary
236
+ d = analysis["direction"]
237
+ lines.append("DIRECTIONAL ANALYSIS:")
238
+ lines.append(f" Left-Right shift: {d['left_right']:.1f} mm "
239
+ f"({'rightward' if d['left_right'] > 0 else 'leftward'})")
240
+ lines.append(f" Anterior-Posterior shift: {d['anterior_posterior']:.1f} mm "
241
+ f"({'anterior' if d['anterior_posterior'] > 0 else 'posterior'})")
242
+ lines.append(f" Superior-Inferior shift: {d['superior_inferior']:.1f} mm "
243
+ f"({'superior' if d['superior_inferior'] > 0 else 'inferior'})")
244
+
245
+ return "\n".join(lines)
246
+
247
+
248
+ # ---------------------------------------------------------------------------
249
+ # ROI flow LLM interpreter
250
+ # ---------------------------------------------------------------------------
251
+
252
+ class ROIFlowLLM:
253
+ """Send ROI flow analysis to LLM for interpretation."""
254
+
255
+ def __init__(self, model: str = "gpt-5.4-mini", debug: bool = False):
256
+ self.model = model
257
+ self.debug = debug
258
+
259
+ def interpret_roi_flow(self, context: str) -> str:
260
+ """Interpret an ROI flow pattern using the LLM.
261
+
262
+ Args:
263
+ context: structured text from ROIFlowAnalyzer.build_llm_context()
264
+
265
+ Returns:
266
+ LLM interpretation text.
267
+ """
268
+ from src.region_analyzer import _ensure_ssl
269
+ _ensure_ssl()
270
+
271
+ from dotenv import load_dotenv
272
+ load_dotenv()
273
+
274
+ instructions = (
275
+ "You are a neuroscientist interpreting brain state dynamics from a "
276
+ "manifold flow simulation. The user traced a path through a learned "
277
+ "neural manifold (a dimensionality-reduced representation of resting-state "
278
+ "brain dynamics). You are given how each ROI's contribution changed along "
279
+ "this path.\n\n"
280
+ "IMPORTANT: The delta values do NOT mean regions became more or less "
281
+ "'active' in a simple sense. They measure how each ROI's CONTRIBUTION "
282
+ "to the overall brain state shifted — some regions contribute more to the "
283
+ "new state, some less. A positive delta means the region became a stronger "
284
+ "contributor; negative means it became a weaker contributor. This is a "
285
+ "transition in the brain's dynamic state.\n\n"
286
+ "Analyze what this particular state transition might mean. Do NOT list "
287
+ "the individual ROI changes — instead, synthesize them into ONE coherent "
288
+ "picture of what cognitive or neural process could underlie this specific "
289
+ "shift in brain dynamics. Consider the spatial pattern (which networks "
290
+ "gained vs lost contribution), the directionality, and what spontaneous "
291
+ "resting-state process would produce this exact transition.\n\n"
292
+ "Keep your response SHORT — 2-3 concise paragraphs maximum.\n\n"
293
+ "Note: ROI deltas are interpolated from a learned resting-state manifold — "
294
+ "interpret transitions as shifts in dynamic brain state, not literal activations."
295
+ )
296
+
297
+ question = context
298
+
299
+ if self.debug:
300
+ import sys
301
+ print(f"\n{'='*60}")
302
+ print(f"[DEBUG PROMPT] ROIFlowLLM.interpret_roi_flow")
303
+ print(f"{'='*60}")
304
+ print(f"INSTRUCTIONS:\n{instructions}")
305
+ print(f"\nINPUT:\n{question}")
306
+ print(f"{'='*60}\n")
307
+ sys.stdout.flush()
308
+
309
+ try:
310
+ from openai import OpenAI
311
+ client = OpenAI(timeout=60.0)
312
+
313
+ resp = client.responses.create(
314
+ model=self.model,
315
+ instructions=instructions,
316
+ input=question,
317
+ reasoning={"effort": "low"},
318
+ max_output_tokens=2500,
319
+ )
320
+
321
+ text = (resp.output_text or "").strip()
322
+ if text:
323
+ return text
324
+
325
+ if getattr(resp, "status", None) == "incomplete":
326
+ reason = getattr(resp.incomplete_details, "reason", "unknown")
327
+ return f"[GPT ERROR] Responses API incomplete: {reason}"
328
+
329
+ return "[GPT ERROR] Responses API returned no visible text."
330
+
331
+ except Exception as e:
332
+ import traceback
333
+ traceback.print_exc()
334
+ return f"[GPT ERROR] {e}"
335
+
336
+ def compare_two_paths(self, context_a: str, context_b: str) -> str:
337
+ """Compare two ROI flow patterns using the LLM.
338
+
339
+ Args:
340
+ context_a: structured text from path A analysis
341
+ context_b: structured text from path B analysis
342
+
343
+ Returns:
344
+ LLM comparison interpretation text.
345
+ """
346
+ from src.region_analyzer import _ensure_ssl
347
+ _ensure_ssl()
348
+
349
+ from dotenv import load_dotenv
350
+ load_dotenv()
351
+
352
+ instructions = (
353
+ "You are a neuroscientist comparing TWO different brain state "
354
+ "transitions observed in a neural manifold flow simulation. "
355
+ "Each path represents a different trajectory through the manifold, "
356
+ "producing different shifts in ROI contributions.\n\n"
357
+ "You are given the ROI flow analysis for Path A and Path B.\n\n"
358
+ "Compare them: What is SIMILAR between the two transitions? "
359
+ "What is DIFFERENT? Which cognitive or neural processes might "
360
+ "explain the divergence? Which path represents a more dramatic "
361
+ "state change? Are they transitions within the same network or "
362
+ "do they involve fundamentally different networks?\n\n"
363
+ "Keep your response SHORT — 2-3 concise paragraphs maximum.\n\n"
364
+ "Note: both paths are trajectories through a learned resting-state manifold — "
365
+ "compare them as different dynamic regimes, not literal biological events."
366
+ )
367
+
368
+ question = (
369
+ "=== PATH A ===\n" + context_a + "\n\n"
370
+ "=== PATH B ===\n" + context_b
371
+ )
372
+
373
+ if self.debug:
374
+ import sys
375
+ print(f"\n{'='*60}")
376
+ print(f"[DEBUG PROMPT] ROIFlowLLM.compare_two_paths")
377
+ print(f"{'='*60}")
378
+ print(f"INSTRUCTIONS:\n{instructions}")
379
+ print(f"\nINPUT:\n{question}")
380
+ print(f"{'='*60}\n")
381
+ sys.stdout.flush()
382
+
383
+ try:
384
+ from openai import OpenAI
385
+ client = OpenAI(timeout=90.0)
386
+
387
+ resp = client.responses.create(
388
+ model=self.model,
389
+ instructions=instructions,
390
+ input=question,
391
+ reasoning={"effort": "low"},
392
+ max_output_tokens=3000,
393
+ )
394
+
395
+ text = (resp.output_text or "").strip()
396
+ if text:
397
+ return text
398
+ return "[GPT ERROR] No response text."
399
+
400
+ except Exception as e:
401
+ import traceback
402
+ traceback.print_exc()
403
+ return f"[GPT ERROR] {e}"
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ fastapi==0.115.6
2
+ uvicorn[standard]==0.34.0
3
+ numpy==2.2.1
4
+ scipy==1.15.1
5
+ python-dotenv==1.0.1
6
+ openai==1.59.6