fscpy 2.0.1__tar.gz → 2.0.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {fscpy-2.0.1 → fscpy-2.0.2}/PKG-INFO +77 -10
- fscpy-2.0.2/README.md +190 -0
- {fscpy-2.0.1 → fscpy-2.0.2}/pyproject.toml +1 -1
- {fscpy-2.0.1 → fscpy-2.0.2}/src/fsc/parcellation/__init__.py +2 -1
- {fscpy-2.0.1 → fscpy-2.0.2}/src/fsc/parcellation/connectivity.py +135 -83
- fscpy-2.0.2/src/fsc/parcellation/streamlines.py +121 -0
- {fscpy-2.0.1 → fscpy-2.0.2}/src/fsc/solver.py +445 -445
- {fscpy-2.0.1 → fscpy-2.0.2}/src/fsc/voxel/fod.py +11 -8
- {fscpy-2.0.1 → fscpy-2.0.2}/src/fsc/voxel/roi.py +1 -1
- {fscpy-2.0.1 → fscpy-2.0.2}/src/fscpy.egg-info/PKG-INFO +77 -10
- {fscpy-2.0.1 → fscpy-2.0.2}/src/fscpy.egg-info/SOURCES.txt +1 -0
- fscpy-2.0.2/tests/test_parcellation.py +97 -0
- fscpy-2.0.2/tests/test_streamlines.py +79 -0
- {fscpy-2.0.1 → fscpy-2.0.2}/tests/test_voxel.py +70 -2
- fscpy-2.0.1/README.md +0 -123
- fscpy-2.0.1/src/fsc/parcellation/streamlines.py +0 -65
- fscpy-2.0.1/tests/test_parcellation.py +0 -53
- {fscpy-2.0.1 → fscpy-2.0.2}/LICENSE +0 -0
- {fscpy-2.0.1 → fscpy-2.0.2}/setup.cfg +0 -0
- {fscpy-2.0.1 → fscpy-2.0.2}/src/fsc/__init__.py +0 -0
- {fscpy-2.0.1 → fscpy-2.0.2}/src/fsc/utils.py +0 -0
- {fscpy-2.0.1 → fscpy-2.0.2}/src/fsc/voxel/__init__.py +0 -0
- {fscpy-2.0.1 → fscpy-2.0.2}/src/fsc/voxel/support.py +0 -0
- {fscpy-2.0.1 → fscpy-2.0.2}/src/fsc/weighted_graph.py +0 -0
- {fscpy-2.0.1 → fscpy-2.0.2}/src/fscpy.egg-info/dependency_links.txt +0 -0
- {fscpy-2.0.1 → fscpy-2.0.2}/src/fscpy.egg-info/requires.txt +0 -0
- {fscpy-2.0.1 → fscpy-2.0.2}/src/fscpy.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fscpy
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.2
|
|
4
4
|
Summary: Function-structure coupling on parcellation and FOD-derived voxel graphs
|
|
5
5
|
Author: Viljami Sairanen
|
|
6
6
|
License: GNU GENERAL PUBLIC LICENSE
|
|
@@ -699,7 +699,7 @@ Dynamic: license-file
|
|
|
699
699
|
|
|
700
700
|
The Python package is imported as `fsc` and provides two workflows:
|
|
701
701
|
|
|
702
|
-
- **Parcellation FSC:** a structural connectivity (SC) matrix and a functional connectivity (FC) matrix produce a symmetric matrix of structural-edge support.
|
|
702
|
+
- **Parcellation FSC:** a structural connectivity (SC) matrix and a functional connectivity (FC) matrix produce a symmetric matrix of structural-edge support. Related publication: the Network Neuroscience paper [doi:10.1162/NETN.a.589](https://doi.org/10.1162/NETN.a.589). The paper's joint formulation is available as `method="joint"` for FC without cycles, and a pairwise variant for dense FC is the default; see [Relation to the publications](#relation-to-the-publications).
|
|
703
703
|
- **Voxelwise FSC:** a spherical-harmonic fibre orientation distribution (FOD) defines a weighted white-matter voxel graph. One supernode per functional parcel connects to neighbouring white-matter voxels through FOD-supported interfaces. The output is a voxelwise map of white-matter edge support. This version is explained in the Aperture Neuro paper [doi:10.52294/001c.172225](https://doi.org/10.52294/001c.172225).
|
|
704
704
|
|
|
705
705
|
These workflows use different structural graphs, so their numerical outputs are **not interchangeable**.
|
|
@@ -719,7 +719,6 @@ On Windows PowerShell, use double quotes around extras, for example `python -m p
|
|
|
719
719
|
|
|
720
720
|
The ENIGMA example also requires the external [ENIGMA Toolbox](https://github.com/MICA-MNI/ENIGMA), which is not a dependency of FSC. Please, refer to their installation instructions if needed.
|
|
721
721
|
|
|
722
|
-
|
|
723
722
|
## Examples
|
|
724
723
|
|
|
725
724
|
The scripts run from the repository root after installation:
|
|
@@ -730,7 +729,7 @@ The scripts run from the repository root after installation:
|
|
|
730
729
|
| `python examples/enigma.py` | ENIGMA Toolbox's included HCP SC and FC matrices | Side-by-side SC, FC and FSC heatmaps |
|
|
731
730
|
| `python examples/voxel.py` | Generated synthetic SH FOD and two parcel voxels | A small 3D white-matter support map |
|
|
732
731
|
|
|
733
|
-
The synthetic voxel example requires `.[voxel]` but **no downloaded MRI data**. It constructs an x-oriented FOD bundle, attaches two functional ROI supernodes and evaluates the resulting support. The ENIGMA example follows the bundled cortical connectivity matrices returned by `load_sc()` and `load_fc()`.
|
|
732
|
+
The synthetic voxel example requires `.[voxel]` but **no downloaded MRI data**. It constructs an x-oriented FOD bundle in the MRtrix3 SH convention, attaches two functional ROI supernodes and evaluates the resulting support. The ENIGMA example follows the bundled cortical connectivity matrices returned by `load_sc()` and `load_fc()`.
|
|
734
733
|
|
|
735
734
|
### Parcellation workflow
|
|
736
735
|
|
|
@@ -753,7 +752,50 @@ support = fsc.solve_from_parcellations(sc, fc)
|
|
|
753
752
|
print(support)
|
|
754
753
|
```
|
|
755
754
|
|
|
756
|
-
`solve_from_parcellations()` returns the **symmetric support matrix**, not an `FSCResult` object. In this example, the only structural path connecting parcels 1 and 4 is 1–0–2–4. Its three unit-conductance edges each carry support `1/3
|
|
755
|
+
`solve_from_parcellations()` returns the **symmetric support matrix**, not an `FSCResult` object. In this example, the only structural path connecting parcels 1 and 4 is 1–0–2–4. Its three unit-conductance edges each carry support `1/3` and the side branch 2–3 carries zero current.
|
|
756
|
+
|
|
757
|
+
FC must be square and symmetric. Only finite, positive values above the diagonal are used. Negative, zero and non-finite values are ignored. The papers used Fisher *z*-transformed FC. The fscpy uses whatever values it is given. The output scales linearly with the FC magnitudes.
|
|
758
|
+
|
|
759
|
+
`solve_from_parcellations()` has two methods:
|
|
760
|
+
|
|
761
|
+
- `method="pairwise"` (default) solves each FC pair separately and sums the absolute edge currents. It works for any FC matrix.
|
|
762
|
+
- `method="joint"` solves all FC constraints in one modified-nodal-analysis system as in the Network Neuroscience paper (Eq. 11). Returns absolute edge currents. Requires the FC pairs to contain no cycles, e.g. a set of disjoint pairs like the FiberCup example. Otherwise, error is raised. Three regions with positive FC between all of them already form a cycle. So, a dense FC matrix has likely no exact joint solution.
|
|
763
|
+
|
|
764
|
+
```python
|
|
765
|
+
fc = np.zeros((5, 5))
|
|
766
|
+
fc[1, 4] = fc[4, 1] = 1
|
|
767
|
+
fc[3, 4] = fc[4, 3] = 1 # two pairs sharing node 4: no cycle
|
|
768
|
+
|
|
769
|
+
joint = fsc.solve_from_parcellations(sc, fc, method="joint")
|
|
770
|
+
pairwise = fsc.solve_from_parcellations(sc, fc) # different for several pairs
|
|
771
|
+
```
|
|
772
|
+
|
|
773
|
+
The two methods agree for a single FC pair and generally differ for several, because the joint solve combines the constraints in one potential field while the pairwise method sums the magnitudes of separate solutions.
|
|
774
|
+
|
|
775
|
+
#### Mapping support to streamlines
|
|
776
|
+
|
|
777
|
+
The support matrix can be mapped back onto a tractogram so that each streamline gets a weight for visualisation, filtering, or analyses. The SC matrix does not have to come from fscpy. Any tool that writes a per-streamline parcel assignment, such as MRtrix3 `tck2connectome -out_assignments`, works.
|
|
778
|
+
|
|
779
|
+
```python
|
|
780
|
+
from fsc.parcellation import streamline_support
|
|
781
|
+
|
|
782
|
+
assignments = np.loadtxt("assignments.txt", dtype=int) # two parcel labels per streamline
|
|
783
|
+
weights = np.loadtxt("sift2_weights.txt") # optional, see below
|
|
784
|
+
|
|
785
|
+
values = streamline_support(support, assignments, weights)
|
|
786
|
+
np.savetxt("fsc_streamline_weights.txt", values) # one value per streamline, input order
|
|
787
|
+
```
|
|
788
|
+
|
|
789
|
+
The support of each parcel pair is split among the streamlines connecting that pair in proportion to their weights. So the values of each pair's streamlines sum to `support[a, b]`. This holds for both methods and for SC from any source or normalisation. Only the relative weights within a pair matter.
|
|
790
|
+
|
|
791
|
+
Requirements:
|
|
792
|
+
|
|
793
|
+
- **Assignments:** exactly two labels per streamline, numbered from 1 in SC matrix order, with 0 for an unassigned endpoint. This is the default `tck2connectome -out_assignments` format. Options that write more labels per streamline (e.g. `-assignment_all_voxels`), or that add a row for node 0 to the matrix (`-keep_unassigned`) break this correspondence!
|
|
794
|
+
- **Weights:** use the per-streamline weights the SC was built from. Omit them for streamline counts, pass the SIFT2 weights file if the SC used `-tck_weights_in`, or `1 / length` for length-scaled SC. Non-finite or non-positive weights mark a streamline as invalid.
|
|
795
|
+
- **Output:** one non-negative value per input streamline, in input order. Unassigned, invalid and self-connecting streamlines get 0. If the SC has an edge with no streamline in `assignments`, that edge's support is not mapped.
|
|
796
|
+
|
|
797
|
+
For signed streamline currents from a potential vector computed with `fsc.solve_fsc()`, use `streamline_currents()`. If SC is built inside fscpy from streamline resistances, use `sc_from_streamlines()`.
|
|
798
|
+
|
|
757
799
|
|
|
758
800
|
### Voxel workflow
|
|
759
801
|
|
|
@@ -783,15 +825,40 @@ support = sum_pairwise_support(
|
|
|
783
825
|
nib.save(nib.Nifti1Image(support, fod_image.affine), 'fsc_support.nii.gz')
|
|
784
826
|
```
|
|
785
827
|
|
|
786
|
-
|
|
828
|
+
#### Input conventions
|
|
787
829
|
|
|
788
|
-
|
|
830
|
+
- **SH basis.** The FOD image must contain SH coefficients in the MRtrix3 convention, as written by `dwi2fod`. This is DIPY's `tournier07` basis with `legacy=False`, which is the default. FODs in the older MRtrix 0.2 convention need `legacy=True`.
|
|
831
|
+
Since version 2.0.2 the default is `legacy=False`; earlier versions defaulted to `legacy=True`. Pass `legacy=True` explicitly to reproduce results from those versions.
|
|
832
|
+
- **Same settings in both calls.** Pass identical `sphere`, `basis`, `legacy`, `kappa`, `kernel_power` and `orientation_floor` values to `graph_from_fod()` and `add_roi_nodes()`. They are not cross-checked.
|
|
833
|
+
- **Frame.** Neighbour directions are computed in world coordinates from the image affine, so the SH coefficients must be defined in that same frame, as MRtrix3 does. The image must have a correct affine.
|
|
834
|
+
- **Registration.** `labels_in_fod_space()` uses nearest-neighbour resampling and assumes the images are **already spatially registered**. It does not register images or reorient SH coefficients.
|
|
835
|
+
- **Masks.** An optional `wm_mask` restricts graph voxels and an optional `exclusion_mask` removes voxels. The FOD-norm threshold is always applied in addition to `wm_mask`.
|
|
836
|
+
- **ROIs.** ROI voxels must have positive labels, lie outside the selected WM voxel graph, and every ROI must have at least one FOD-supported WM contact, otherwise `add_roi_nodes()` raises an error. An optional `tissue_mask` excludes non-tissue ROI voxels.
|
|
837
|
+
|
|
838
|
+
#### How the graph is built
|
|
839
|
+
|
|
840
|
+
- **Nodes.** Voxels whose SH coefficient vector has a Euclidean norm above `fod_norm_threshold` (default 0.25), inside `wm_mask` if given. This threshold depends on the FOD intensity scale, so check it against your FOD normalisation.
|
|
841
|
+
- **Edges.** Each voxel is connected to its 26 neighbours (13 unique directions). The conductance of an interface is `sqrt(s_i · s_j) / ℓ`, where `ℓ` is the physical distance between voxel centres and `s` is the directional support of each endpoint. Interfaces below `min_conductance` (default 1e-12) are dropped.
|
|
842
|
+
- **Directional support.** The FOD amplitude along the interface direction is evaluated with an angular averaging kernel, with weights proportional to `exp(κ·|cos θ|^p)` over the sampling sphere (default `repulsion724`, `kappa=8`, `kernel_power=2`). It is clipped at zero after averaging, reduced by `orientation_floor` times the mean positive FOD amplitude of the voxel, and clipped at zero again. This kernel averaging is an implementation detail: the papers evaluate the FOD directly along the interface direction.
|
|
843
|
+
- **ROI supernodes.** Each ROI is a graph node. Labelled voxels outside the WM graph are ROI tissue. Each one touches the WM graph through its six face neighbours. The conductance of a contact is `interface_scale` times the *WM voxel's* directional support along the face normal, divided by the voxel size. Diagonal contacts are not used, and support is taken from the WM side only. All ROI supernodes are present in the graph when any pair is solved.
|
|
844
|
+
- **Support map.** Voxel support is the sum of absolute currents on the WM–WM edges incident to a voxel, accumulated over all FC pairs. ROI–WM interface edges are excluded.
|
|
845
|
+
|
|
846
|
+
#### Solving
|
|
847
|
+
|
|
848
|
+
The voxel workflow applies each finite, positive FC pair **independently in the mathematical model**. For efficiency, the implementation solves reusable grounded-Laplacian basis fields relative to one ROI per connected component, then reconstructs pair solutions. It accumulates **absolute** structural currents. Pairs without a structural path contribute zero. Specify `pairs=[(0, 1)]` to select one pair by zero-based FC matrix indices.
|
|
849
|
+
|
|
850
|
+
`solver` can be `'auto'`, `'cg'` or `'direct'`; the default `rtol` is `1e-4`. Each solution is checked for current conservation, and the check is relative to the injected current, so it does not depend on the scale of the conductances. One potential field is stored per active ROI, and run time grows with the number of FC pairs.
|
|
789
851
|
|
|
790
852
|
## Numerical interpretation
|
|
791
853
|
|
|
792
|
-
In both workflows, positive FC
|
|
854
|
+
In both workflows, only positive FC values define imposed potential differences. Negative values are ignored. SC values and FOD interface weights act as conductances, **not lengths or resistances**. FSC reports how the weighted structural graph supports the imposed correlations. It is not a direct measurement of axonal traffic or a uniquely determined anatomical tract.
|
|
855
|
+
|
|
856
|
+
The low-level `fsc.solve_fsc()` interface solves **simultaneous** functional constraints through modified nodal analysis and returns an `FSCResult` (potentials, currents and residual diagnostics). The constraints must be mutually compatible. The imposed potential differences must sum to zero around every cycle of constrained pairs. Constraints without cycles, e.g. the FiberCup experiment, always satisfy this. Whereas a dense FC matrix likely does not because it imposes far more potential differences than the graph has free potentials. `solve_from_parcellations(..., method="joint")` wraps this interface and checks for cycles before solving. For dense FC, use `solve_from_parcellations()` with the default `method="pairwise"`, or `solve_fsc_voxel()`, which evaluate FC pairs separately and sum their absolute structural-edge currents.
|
|
857
|
+
|
|
858
|
+
## Relation to the publications
|
|
793
859
|
|
|
794
|
-
The
|
|
860
|
+
- **Network Neuroscience (parcellation workflow).** The paper formulates FSC as one joint modified-nodal-analysis system (Eq. 11), with all constraints solved together. `solve_from_parcellations(..., method="joint")` implements it for FC pairs without cycles like in the FiberCup specificity test. When FC pairs form cycles, as many dense FC matrices, the constraints cannot all hold at once andthe joint system has no exact solution. For such inputs use the default `method="pairwise"`, which solves each pair separately and sums absolute currents. The two methods agree for a single constraint.
|
|
861
|
+
- **Aperture Neuro (voxel workflow).** The Methods describe imposing each selected FC value separately and accumulating the resulting current magnitudes, which is what `sum_pairwise_support()` does. The directional-support kernel, the six-face ROI contacts and the 0.25 norm threshold are implementation choices not given in the paper.
|
|
795
862
|
|
|
796
863
|
## Tests
|
|
797
864
|
|
|
@@ -801,7 +868,7 @@ Run the small deterministic tests without downloading neuroimaging datasets:
|
|
|
801
868
|
python -m pytest -q
|
|
802
869
|
```
|
|
803
870
|
|
|
804
|
-
The tests cover the five-node analytical solution, general MNA, negative FC, empty FC, ROI-basis accumulation, disconnected ROIs,
|
|
871
|
+
The tests cover the five-node analytical solution, general MNA, the joint parcellation method and its rejection of FC with cycles, mapping support to streamlines, negative FC, empty FC, ROI-basis accumulation, disconnected ROIs, WM-only voxel support, and the scale-invariance of the solver's convergence check. Optional tests run when NiBabel and DIPY are installed: a synthetic-FOD end-to-end example, and a check that the default SH basis reproduces amplitudes computed by MRtrix3 (`sh2amp`) from a reference FiberCup FOD stored in `tests/data/`.
|
|
805
872
|
|
|
806
873
|
## Research
|
|
807
874
|
|
fscpy-2.0.2/README.md
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# fscpy
|
|
2
|
+
|
|
3
|
+
**Function–structure coupling (FSC)** maps functional correlations onto the structural connections that can support them. FSC represents structural connectivity as weighted graph conductances, imposes functional connectivity as potential differences, and measures the resulting currents on structural edges.
|
|
4
|
+
|
|
5
|
+
The Python package is imported as `fsc` and provides two workflows:
|
|
6
|
+
|
|
7
|
+
- **Parcellation FSC:** a structural connectivity (SC) matrix and a functional connectivity (FC) matrix produce a symmetric matrix of structural-edge support. Related publication: the Network Neuroscience paper [doi:10.1162/NETN.a.589](https://doi.org/10.1162/NETN.a.589). The paper's joint formulation is available as `method="joint"` for FC without cycles, and a pairwise variant for dense FC is the default; see [Relation to the publications](#relation-to-the-publications).
|
|
8
|
+
- **Voxelwise FSC:** a spherical-harmonic fibre orientation distribution (FOD) defines a weighted white-matter voxel graph. One supernode per functional parcel connects to neighbouring white-matter voxels through FOD-supported interfaces. The output is a voxelwise map of white-matter edge support. This version is explained in the Aperture Neuro paper [doi:10.52294/001c.172225](https://doi.org/10.52294/001c.172225).
|
|
9
|
+
|
|
10
|
+
These workflows use different structural graphs, so their numerical outputs are **not interchangeable**.
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
From the repository directory, preferably in a virtual environment:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
python -m pip install -e . # Parcellation FSC (NumPy, SciPy)
|
|
18
|
+
python -m pip install -e '.[voxel]' # Also NiBabel and DIPY
|
|
19
|
+
python -m pip install -e '.[test]' # Run tests with pytest
|
|
20
|
+
python -m pip install -e '.[examples]' # Matplotlib for the ENIGMA plot
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
On Windows PowerShell, use double quotes around extras, for example `python -m pip install -e ".[voxel,test,examples]"`.
|
|
24
|
+
|
|
25
|
+
The ENIGMA example also requires the external [ENIGMA Toolbox](https://github.com/MICA-MNI/ENIGMA), which is not a dependency of FSC. Please, refer to their installation instructions if needed.
|
|
26
|
+
|
|
27
|
+
## Examples
|
|
28
|
+
|
|
29
|
+
The scripts run from the repository root after installation:
|
|
30
|
+
|
|
31
|
+
| Script | Data | Output |
|
|
32
|
+
| --- | --- | --- |
|
|
33
|
+
| `python examples/parcellation.py` | Explicit 5×5 SC and FC matrices | A 5×5 symmetric FSC matrix with a known three-edge path |
|
|
34
|
+
| `python examples/enigma.py` | ENIGMA Toolbox's included HCP SC and FC matrices | Side-by-side SC, FC and FSC heatmaps |
|
|
35
|
+
| `python examples/voxel.py` | Generated synthetic SH FOD and two parcel voxels | A small 3D white-matter support map |
|
|
36
|
+
|
|
37
|
+
The synthetic voxel example requires `.[voxel]` but **no downloaded MRI data**. It constructs an x-oriented FOD bundle in the MRtrix3 SH convention, attaches two functional ROI supernodes and evaluates the resulting support. The ENIGMA example follows the bundled cortical connectivity matrices returned by `load_sc()` and `load_fc()`.
|
|
38
|
+
|
|
39
|
+
### Parcellation workflow
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
import numpy as np
|
|
43
|
+
import fsc
|
|
44
|
+
|
|
45
|
+
sc = np.array([
|
|
46
|
+
[0, 1, 1, 0, 0],
|
|
47
|
+
[1, 0, 0, 0, 0],
|
|
48
|
+
[1, 0, 0, 1, 1],
|
|
49
|
+
[0, 0, 1, 0, 0],
|
|
50
|
+
[0, 0, 1, 0, 0],
|
|
51
|
+
], dtype=float)
|
|
52
|
+
|
|
53
|
+
fc = np.zeros((5, 5))
|
|
54
|
+
fc[1, 4] = fc[4, 1] = 1
|
|
55
|
+
|
|
56
|
+
support = fsc.solve_from_parcellations(sc, fc)
|
|
57
|
+
print(support)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`solve_from_parcellations()` returns the **symmetric support matrix**, not an `FSCResult` object. In this example, the only structural path connecting parcels 1 and 4 is 1–0–2–4. Its three unit-conductance edges each carry support `1/3` and the side branch 2–3 carries zero current.
|
|
61
|
+
|
|
62
|
+
FC must be square and symmetric. Only finite, positive values above the diagonal are used. Negative, zero and non-finite values are ignored. The papers used Fisher *z*-transformed FC. The fscpy uses whatever values it is given. The output scales linearly with the FC magnitudes.
|
|
63
|
+
|
|
64
|
+
`solve_from_parcellations()` has two methods:
|
|
65
|
+
|
|
66
|
+
- `method="pairwise"` (default) solves each FC pair separately and sums the absolute edge currents. It works for any FC matrix.
|
|
67
|
+
- `method="joint"` solves all FC constraints in one modified-nodal-analysis system as in the Network Neuroscience paper (Eq. 11). Returns absolute edge currents. Requires the FC pairs to contain no cycles, e.g. a set of disjoint pairs like the FiberCup example. Otherwise, error is raised. Three regions with positive FC between all of them already form a cycle. So, a dense FC matrix has likely no exact joint solution.
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
fc = np.zeros((5, 5))
|
|
71
|
+
fc[1, 4] = fc[4, 1] = 1
|
|
72
|
+
fc[3, 4] = fc[4, 3] = 1 # two pairs sharing node 4: no cycle
|
|
73
|
+
|
|
74
|
+
joint = fsc.solve_from_parcellations(sc, fc, method="joint")
|
|
75
|
+
pairwise = fsc.solve_from_parcellations(sc, fc) # different for several pairs
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
The two methods agree for a single FC pair and generally differ for several, because the joint solve combines the constraints in one potential field while the pairwise method sums the magnitudes of separate solutions.
|
|
79
|
+
|
|
80
|
+
#### Mapping support to streamlines
|
|
81
|
+
|
|
82
|
+
The support matrix can be mapped back onto a tractogram so that each streamline gets a weight for visualisation, filtering, or analyses. The SC matrix does not have to come from fscpy. Any tool that writes a per-streamline parcel assignment, such as MRtrix3 `tck2connectome -out_assignments`, works.
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from fsc.parcellation import streamline_support
|
|
86
|
+
|
|
87
|
+
assignments = np.loadtxt("assignments.txt", dtype=int) # two parcel labels per streamline
|
|
88
|
+
weights = np.loadtxt("sift2_weights.txt") # optional, see below
|
|
89
|
+
|
|
90
|
+
values = streamline_support(support, assignments, weights)
|
|
91
|
+
np.savetxt("fsc_streamline_weights.txt", values) # one value per streamline, input order
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The support of each parcel pair is split among the streamlines connecting that pair in proportion to their weights. So the values of each pair's streamlines sum to `support[a, b]`. This holds for both methods and for SC from any source or normalisation. Only the relative weights within a pair matter.
|
|
95
|
+
|
|
96
|
+
Requirements:
|
|
97
|
+
|
|
98
|
+
- **Assignments:** exactly two labels per streamline, numbered from 1 in SC matrix order, with 0 for an unassigned endpoint. This is the default `tck2connectome -out_assignments` format. Options that write more labels per streamline (e.g. `-assignment_all_voxels`), or that add a row for node 0 to the matrix (`-keep_unassigned`) break this correspondence!
|
|
99
|
+
- **Weights:** use the per-streamline weights the SC was built from. Omit them for streamline counts, pass the SIFT2 weights file if the SC used `-tck_weights_in`, or `1 / length` for length-scaled SC. Non-finite or non-positive weights mark a streamline as invalid.
|
|
100
|
+
- **Output:** one non-negative value per input streamline, in input order. Unassigned, invalid and self-connecting streamlines get 0. If the SC has an edge with no streamline in `assignments`, that edge's support is not mapped.
|
|
101
|
+
|
|
102
|
+
For signed streamline currents from a potential vector computed with `fsc.solve_fsc()`, use `streamline_currents()`. If SC is built inside fscpy from streamline resistances, use `sc_from_streamlines()`.
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
### Voxel workflow
|
|
106
|
+
|
|
107
|
+
The runnable demonstration is [`examples/voxel.py`](examples/voxel.py). For actual FOD images and registered parcel labels, the workflow is:
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
import nibabel as nib
|
|
111
|
+
import numpy as np
|
|
112
|
+
import fsc
|
|
113
|
+
from fsc.voxel import graph_from_fod, labels_in_fod_space, add_roi_nodes, sum_pairwise_support
|
|
114
|
+
|
|
115
|
+
fod_image = nib.load('fod_sh.nii.gz')
|
|
116
|
+
parcel_image = nib.load('parcellation_registered.nii.gz')
|
|
117
|
+
labels = labels_in_fod_space(parcel_image, fod_image)
|
|
118
|
+
|
|
119
|
+
fc = np.load('fc.npy')
|
|
120
|
+
roi_labels = np.load('roi_labels.npy') # must match the FC matrix order
|
|
121
|
+
|
|
122
|
+
wm_graph, voxel_mapping = graph_from_fod(fod_image, fod_norm_threshold=0.25)
|
|
123
|
+
graph, parcellation = add_roi_nodes(
|
|
124
|
+
wm_graph, voxel_mapping, fod_image, labels, roi_labels=roi_labels
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
support = sum_pairwise_support(
|
|
128
|
+
graph, voxel_mapping, parcellation, fc, solver='cg', rtol=1e-6
|
|
129
|
+
)
|
|
130
|
+
nib.save(nib.Nifti1Image(support, fod_image.affine), 'fsc_support.nii.gz')
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
#### Input conventions
|
|
134
|
+
|
|
135
|
+
- **SH basis.** The FOD image must contain SH coefficients in the MRtrix3 convention, as written by `dwi2fod`. This is DIPY's `tournier07` basis with `legacy=False`, which is the default. FODs in the older MRtrix 0.2 convention need `legacy=True`.
|
|
136
|
+
Since version 2.0.2 the default is `legacy=False`; earlier versions defaulted to `legacy=True`. Pass `legacy=True` explicitly to reproduce results from those versions.
|
|
137
|
+
- **Same settings in both calls.** Pass identical `sphere`, `basis`, `legacy`, `kappa`, `kernel_power` and `orientation_floor` values to `graph_from_fod()` and `add_roi_nodes()`. They are not cross-checked.
|
|
138
|
+
- **Frame.** Neighbour directions are computed in world coordinates from the image affine, so the SH coefficients must be defined in that same frame, as MRtrix3 does. The image must have a correct affine.
|
|
139
|
+
- **Registration.** `labels_in_fod_space()` uses nearest-neighbour resampling and assumes the images are **already spatially registered**. It does not register images or reorient SH coefficients.
|
|
140
|
+
- **Masks.** An optional `wm_mask` restricts graph voxels and an optional `exclusion_mask` removes voxels. The FOD-norm threshold is always applied in addition to `wm_mask`.
|
|
141
|
+
- **ROIs.** ROI voxels must have positive labels, lie outside the selected WM voxel graph, and every ROI must have at least one FOD-supported WM contact, otherwise `add_roi_nodes()` raises an error. An optional `tissue_mask` excludes non-tissue ROI voxels.
|
|
142
|
+
|
|
143
|
+
#### How the graph is built
|
|
144
|
+
|
|
145
|
+
- **Nodes.** Voxels whose SH coefficient vector has a Euclidean norm above `fod_norm_threshold` (default 0.25), inside `wm_mask` if given. This threshold depends on the FOD intensity scale, so check it against your FOD normalisation.
|
|
146
|
+
- **Edges.** Each voxel is connected to its 26 neighbours (13 unique directions). The conductance of an interface is `sqrt(s_i · s_j) / ℓ`, where `ℓ` is the physical distance between voxel centres and `s` is the directional support of each endpoint. Interfaces below `min_conductance` (default 1e-12) are dropped.
|
|
147
|
+
- **Directional support.** The FOD amplitude along the interface direction is evaluated with an angular averaging kernel, with weights proportional to `exp(κ·|cos θ|^p)` over the sampling sphere (default `repulsion724`, `kappa=8`, `kernel_power=2`). It is clipped at zero after averaging, reduced by `orientation_floor` times the mean positive FOD amplitude of the voxel, and clipped at zero again. This kernel averaging is an implementation detail: the papers evaluate the FOD directly along the interface direction.
|
|
148
|
+
- **ROI supernodes.** Each ROI is a graph node. Labelled voxels outside the WM graph are ROI tissue. Each one touches the WM graph through its six face neighbours. The conductance of a contact is `interface_scale` times the *WM voxel's* directional support along the face normal, divided by the voxel size. Diagonal contacts are not used, and support is taken from the WM side only. All ROI supernodes are present in the graph when any pair is solved.
|
|
149
|
+
- **Support map.** Voxel support is the sum of absolute currents on the WM–WM edges incident to a voxel, accumulated over all FC pairs. ROI–WM interface edges are excluded.
|
|
150
|
+
|
|
151
|
+
#### Solving
|
|
152
|
+
|
|
153
|
+
The voxel workflow applies each finite, positive FC pair **independently in the mathematical model**. For efficiency, the implementation solves reusable grounded-Laplacian basis fields relative to one ROI per connected component, then reconstructs pair solutions. It accumulates **absolute** structural currents. Pairs without a structural path contribute zero. Specify `pairs=[(0, 1)]` to select one pair by zero-based FC matrix indices.
|
|
154
|
+
|
|
155
|
+
`solver` can be `'auto'`, `'cg'` or `'direct'`; the default `rtol` is `1e-4`. Each solution is checked for current conservation, and the check is relative to the injected current, so it does not depend on the scale of the conductances. One potential field is stored per active ROI, and run time grows with the number of FC pairs.
|
|
156
|
+
|
|
157
|
+
## Numerical interpretation
|
|
158
|
+
|
|
159
|
+
In both workflows, only positive FC values define imposed potential differences. Negative values are ignored. SC values and FOD interface weights act as conductances, **not lengths or resistances**. FSC reports how the weighted structural graph supports the imposed correlations. It is not a direct measurement of axonal traffic or a uniquely determined anatomical tract.
|
|
160
|
+
|
|
161
|
+
The low-level `fsc.solve_fsc()` interface solves **simultaneous** functional constraints through modified nodal analysis and returns an `FSCResult` (potentials, currents and residual diagnostics). The constraints must be mutually compatible. The imposed potential differences must sum to zero around every cycle of constrained pairs. Constraints without cycles, e.g. the FiberCup experiment, always satisfy this. Whereas a dense FC matrix likely does not because it imposes far more potential differences than the graph has free potentials. `solve_from_parcellations(..., method="joint")` wraps this interface and checks for cycles before solving. For dense FC, use `solve_from_parcellations()` with the default `method="pairwise"`, or `solve_fsc_voxel()`, which evaluate FC pairs separately and sum their absolute structural-edge currents.
|
|
162
|
+
|
|
163
|
+
## Relation to the publications
|
|
164
|
+
|
|
165
|
+
- **Network Neuroscience (parcellation workflow).** The paper formulates FSC as one joint modified-nodal-analysis system (Eq. 11), with all constraints solved together. `solve_from_parcellations(..., method="joint")` implements it for FC pairs without cycles like in the FiberCup specificity test. When FC pairs form cycles, as many dense FC matrices, the constraints cannot all hold at once andthe joint system has no exact solution. For such inputs use the default `method="pairwise"`, which solves each pair separately and sums absolute currents. The two methods agree for a single constraint.
|
|
166
|
+
- **Aperture Neuro (voxel workflow).** The Methods describe imposing each selected FC value separately and accumulating the resulting current magnitudes, which is what `sum_pairwise_support()` does. The directional-support kernel, the six-face ROI contacts and the 0.25 norm threshold are implementation choices not given in the paper.
|
|
167
|
+
|
|
168
|
+
## Tests
|
|
169
|
+
|
|
170
|
+
Run the small deterministic tests without downloading neuroimaging datasets:
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
python -m pytest -q
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
The tests cover the five-node analytical solution, general MNA, the joint parcellation method and its rejection of FC with cycles, mapping support to streamlines, negative FC, empty FC, ROI-basis accumulation, disconnected ROIs, WM-only voxel support, and the scale-invariance of the solver's convergence check. Optional tests run when NiBabel and DIPY are installed: a synthetic-FOD end-to-end example, and a check that the default SH basis reproduces amplitudes computed by MRtrix3 (`sh2amp`) from a reference FiberCup FOD stored in `tests/data/`.
|
|
177
|
+
|
|
178
|
+
## Research
|
|
179
|
+
|
|
180
|
+
The two FSC workflows are described in the following papers:
|
|
181
|
+
|
|
182
|
+
- **Parcellation workflow:** Sairanen, V. *From nodes to pathways: an edge-centric model of brain function–structure coupling via constrained Laplacians*. **Network Neuroscience** (2026). [doi:10.1162/NETN.a.589](https://doi.org/10.1162/NETN.a.589).
|
|
183
|
+
|
|
184
|
+
- **Voxel workflow:** Sairanen, V. *Voxel-wise function-structure coupling with diffusion-weighted and functional MRI*. **Aperture Neuro** (2026). [doi:10.52294/001c.172225](https://doi.org/10.52294/001c.172225).
|
|
185
|
+
|
|
186
|
+
Please, cite the publication corresponding to the workflow used.
|
|
187
|
+
|
|
188
|
+
## License
|
|
189
|
+
|
|
190
|
+
GNU General Public License v3.0; see [LICENSE](LICENSE).
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""FSC on regional parcellation graphs and tractography streamlines."""
|
|
2
2
|
|
|
3
3
|
from .connectivity import graph_from_sc, solve_from_parcellations, edge_currents_to_matrix
|
|
4
|
+
from .streamlines import sc_from_streamlines, streamline_currents, streamline_support
|
|
4
5
|
|
|
5
|
-
__all__ = ["graph_from_sc", "solve_from_parcellations", "edge_currents_to_matrix"]
|
|
6
|
+
__all__ = ["graph_from_sc", "solve_from_parcellations", "edge_currents_to_matrix", "sc_from_streamlines", "streamline_currents", "streamline_support"]
|
|
@@ -1,83 +1,135 @@
|
|
|
1
|
-
"""Parcellation FSC from structural and functional connectivity matrices."""
|
|
2
|
-
|
|
3
|
-
import numpy as np
|
|
4
|
-
from scipy
|
|
5
|
-
from scipy.sparse.
|
|
6
|
-
from
|
|
7
|
-
import
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
1
|
+
"""Parcellation FSC from structural and functional connectivity matrices."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
from scipy import sparse
|
|
5
|
+
from scipy.sparse.csgraph import connected_components
|
|
6
|
+
from scipy.sparse.linalg import factorized
|
|
7
|
+
from warnings import warn
|
|
8
|
+
import fsc
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def graph_from_sc(sc):
|
|
12
|
+
"""Use the upper triangle of a symmetric SC matrix as graph conductances."""
|
|
13
|
+
sc = fsc.validate_symmetric_matrix(sc, "SC")
|
|
14
|
+
source, target = np.nonzero(np.triu(sc, k=1))
|
|
15
|
+
return fsc.WeightedGraph(len(sc), source, target, sc[source, target])
|
|
16
|
+
|
|
17
|
+
def solve_from_parcellations(sc, fc, *, method="pairwise", solver="auto"):
|
|
18
|
+
"""Calculate FSC support from parcellation SC and FC matrices.
|
|
19
|
+
|
|
20
|
+
method="pairwise" solves each positive FC pair separately and sums the
|
|
21
|
+
absolute edge currents. It is defined for any FC matrix.
|
|
22
|
+
|
|
23
|
+
method="joint" solves all FC constraints in one modified-nodal-analysis
|
|
24
|
+
system (Network Neuroscience paper, Eq. 11) and returns absolute edge
|
|
25
|
+
currents. It requires the FC pairs to contain no cycles. Therefore, a dense FC likely has no exact joint solution.
|
|
26
|
+
|
|
27
|
+
solver is passed to fsc.solve_fsc() and is used only by method="joint".
|
|
28
|
+
"""
|
|
29
|
+
if method == "pairwise":
|
|
30
|
+
return _solve_pairwise(sc, fc)
|
|
31
|
+
if method == "joint":
|
|
32
|
+
return _solve_joint(sc, fc, solver=solver)
|
|
33
|
+
raise ValueError(f"Unknown method: {method}")
|
|
34
|
+
|
|
35
|
+
def _solve_pairwise(sc, fc):
|
|
36
|
+
"""Solve each FC pair separately and sum absolute edge currents."""
|
|
37
|
+
graph = graph_from_sc(sc)
|
|
38
|
+
|
|
39
|
+
if np.shape(fc) != (graph.n_nodes, graph.n_nodes):
|
|
40
|
+
raise ValueError("SC and FC dimensions differ.")
|
|
41
|
+
|
|
42
|
+
pairs, values = fsc.fc_pairs(fc)
|
|
43
|
+
edge_support = np.zeros(len(graph.edge_i))
|
|
44
|
+
|
|
45
|
+
if len(pairs) == 0:
|
|
46
|
+
return edge_currents_to_matrix(graph, edge_support, absolute=True)
|
|
47
|
+
|
|
48
|
+
laplacian = graph.laplacian()
|
|
49
|
+
_, component_labels = connected_components(laplacian, directed=False)
|
|
50
|
+
|
|
51
|
+
# Only FC pairs with a structural path can carry current.
|
|
52
|
+
connected = (
|
|
53
|
+
component_labels[pairs[:, 0]] == component_labels[pairs[:, 1]]
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
pairs = pairs[connected]
|
|
57
|
+
values = values[connected]
|
|
58
|
+
|
|
59
|
+
if len(pairs) == 0:
|
|
60
|
+
warn("No structural support for any FC pair.", stacklevel=3)
|
|
61
|
+
return edge_currents_to_matrix(graph, edge_support, absolute=True)
|
|
62
|
+
|
|
63
|
+
# Ground one node in each connected component.
|
|
64
|
+
_, ground_nodes = np.unique(component_labels, return_index=True)
|
|
65
|
+
mask = np.ones(graph.n_nodes, dtype=bool)
|
|
66
|
+
mask[ground_nodes] = False
|
|
67
|
+
|
|
68
|
+
solve = factorized(laplacian[mask][:, mask].tocsc())
|
|
69
|
+
|
|
70
|
+
rhs = np.zeros(graph.n_nodes)
|
|
71
|
+
potentials = np.zeros(graph.n_nodes)
|
|
72
|
+
|
|
73
|
+
for (source, target), value in zip(pairs, values):
|
|
74
|
+
rhs.fill(0)
|
|
75
|
+
rhs[source] = 1.0
|
|
76
|
+
rhs[target] = -1.0
|
|
77
|
+
|
|
78
|
+
potentials.fill(0)
|
|
79
|
+
potentials[mask] = solve(rhs[mask])
|
|
80
|
+
|
|
81
|
+
resistance = potentials[source] - potentials[target]
|
|
82
|
+
potentials *= value / resistance
|
|
83
|
+
|
|
84
|
+
currents = graph.conductance * (
|
|
85
|
+
potentials[graph.edge_i] - potentials[graph.edge_j]
|
|
86
|
+
)
|
|
87
|
+
edge_support += np.abs(currents)
|
|
88
|
+
|
|
89
|
+
return edge_currents_to_matrix(graph, edge_support, absolute=True)
|
|
90
|
+
|
|
91
|
+
def _independent_cycles(pairs, n_nodes):
|
|
92
|
+
"""Number of independent cycles in the graph whose edges are FC pairs."""
|
|
93
|
+
if len(pairs) == 0:
|
|
94
|
+
return 0
|
|
95
|
+
adjacency = sparse.coo_matrix(
|
|
96
|
+
(np.ones(len(pairs)), (pairs[:, 0], pairs[:, 1])),
|
|
97
|
+
shape=(n_nodes, n_nodes),
|
|
98
|
+
)
|
|
99
|
+
n_components, _ = connected_components(adjacency, directed=False)
|
|
100
|
+
return len(pairs) - (n_nodes - n_components)
|
|
101
|
+
|
|
102
|
+
def _solve_joint(sc, fc, *, solver="auto"):
|
|
103
|
+
"""Solve all FC constraints jointly (NetN Eq. 11) for cycle-free FC pairs only. Subnetworks with cycles are not solved!"""
|
|
104
|
+
graph = graph_from_sc(sc)
|
|
105
|
+
|
|
106
|
+
if np.shape(fc) != (graph.n_nodes, graph.n_nodes):
|
|
107
|
+
raise ValueError("SC and FC dimensions differ.")
|
|
108
|
+
|
|
109
|
+
pairs, values = fsc.fc_pairs(fc)
|
|
110
|
+
cycles = _independent_cycles(pairs, graph.n_nodes)
|
|
111
|
+
if cycles > 0:
|
|
112
|
+
raise ValueError(
|
|
113
|
+
f"The FC pairs form {cycles} independent cycle(s). The joint "
|
|
114
|
+
"system has an exact solution only if FC values sum to zero "
|
|
115
|
+
"around every cycle, which correlation values do not. Use "
|
|
116
|
+
"method='pairwise' for dense FC."
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
constraints = fsc.functional_connectivity_from_fc_pairs(
|
|
120
|
+
pairs, values, graph.n_nodes
|
|
121
|
+
)
|
|
122
|
+
result = fsc.solve_fsc(graph, constraints, solver=solver)
|
|
123
|
+
return edge_currents_to_matrix(graph, result.edge_currents, absolute=True)
|
|
124
|
+
|
|
125
|
+
def edge_currents_to_matrix(graph, edge_currents, absolute=False):
|
|
126
|
+
"""Convert graph edge currents to an N-by-N connectivity matrix."""
|
|
127
|
+
matrix = np.zeros((graph.n_nodes, graph.n_nodes))
|
|
128
|
+
for node_i, node_j, current in zip(
|
|
129
|
+
graph.edge_i, graph.edge_j, edge_currents
|
|
130
|
+
):
|
|
131
|
+
if absolute:
|
|
132
|
+
current = abs(current)
|
|
133
|
+
matrix[node_i, node_j] += current
|
|
134
|
+
matrix[node_j, node_i] += current if absolute else -current
|
|
135
|
+
return matrix
|