Spaces:
Sleeping
Sleeping
Deploy MindVisualizer API
Browse files- Dockerfile +17 -0
- README.md +42 -5
- app.py +168 -0
- mindVisualizer/data/roi_flow/mdn_training_all_points.npy +3 -0
- mindVisualizer/data/roi_flow/mdn_universal_all_grid64_entropy.bin +3 -0
- mindVisualizer/data/roi_flow/mdn_universal_all_grid64_mean_xyz3.bin +3 -0
- mindVisualizer/data/roi_flow/mdn_universal_all_grid64_mu1_xyz3.bin +3 -0
- mindVisualizer/data/roi_flow/mdn_universal_all_grid64_mu2_xyz3.bin +3 -0
- mindVisualizer/data/roi_flow/mdn_universal_all_grid64_pi.bin +3 -0
- mindVisualizer/data/roi_flow/mdn_universal_raw_grid64_meta.json +1 -0
- mindVisualizer/data/roi_flow/probe_embed.npy +3 -0
- mindVisualizer/data/roi_flow/probe_roi.npy +3 -0
- mindVisualizer/data/roi_flow/probe_roi_centers.npy +3 -0
- mindVisualizer/data/structures.json +1 -0
- mindVisualizer/src/__init__.py +0 -0
- mindVisualizer/src/field_loader.py +192 -0
- mindVisualizer/src/region_analyzer.py +703 -0
- mindVisualizer/src/roi_flow.py +403 -0
- requirements.txt +6 -0
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:
|
| 3 |
-
emoji: 😻
|
| 4 |
-
colorFrom: gray
|
| 5 |
-
colorTo: pink
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|