roiextract 0.0.2__py3-none-any.whl
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.
- roiextract/__init__.py +6 -0
- roiextract/analytic.py +73 -0
- roiextract/filter.py +154 -0
- roiextract/numerical.py +82 -0
- roiextract/optimize.py +288 -0
- roiextract/quantify.py +178 -0
- roiextract/utils.py +77 -0
- roiextract-0.0.2.dist-info/METADATA +58 -0
- roiextract-0.0.2.dist-info/RECORD +11 -0
- roiextract-0.0.2.dist-info/WHEEL +4 -0
- roiextract-0.0.2.dist-info/licenses/LICENSE +21 -0
roiextract/__init__.py
ADDED
roiextract/analytic.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
|
|
3
|
+
from numpy.linalg import norm
|
|
4
|
+
from scipy.linalg import eig
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def ctf_optimize_ratio_similarity(
|
|
8
|
+
leadfield, template, mask, lambda_, regA=0.001, regB=0.001
|
|
9
|
+
):
|
|
10
|
+
"""
|
|
11
|
+
Optimize a linear combination of similarity with a template CTF and
|
|
12
|
+
within/outside CTF ratio
|
|
13
|
+
|
|
14
|
+
Parameters
|
|
15
|
+
----------
|
|
16
|
+
leadfield: array_like
|
|
17
|
+
Lead field matrix for dipoles with fixed orientation with shape
|
|
18
|
+
(channels, voxels).
|
|
19
|
+
template: array_like
|
|
20
|
+
Template CTF pattern for voxels with shape (voxels,). Only values
|
|
21
|
+
within the ROI are used.
|
|
22
|
+
mask: array_like
|
|
23
|
+
Voxel mask of the region of interest with shape (voxels,). Contains
|
|
24
|
+
ones and zeros for voxels within and outside ROI, respectively.
|
|
25
|
+
lambda_: float
|
|
26
|
+
If 0, only ratio is optimized. If 1, only similarity. Values in between
|
|
27
|
+
allow tweaking the balance between optimization for similarity or ratio.
|
|
28
|
+
regA, regB: float
|
|
29
|
+
Regularization parameters to ensure that it is possible to calculate the
|
|
30
|
+
inverse matrices.
|
|
31
|
+
|
|
32
|
+
Returns
|
|
33
|
+
-------
|
|
34
|
+
w: array
|
|
35
|
+
Spatial filter produced by the optimization.
|
|
36
|
+
|
|
37
|
+
Raises
|
|
38
|
+
------
|
|
39
|
+
ValueError
|
|
40
|
+
If the provided lambda_ value is out of [0, 1] range.
|
|
41
|
+
"""
|
|
42
|
+
if lambda_ > 1.0 or lambda_ < 0.0:
|
|
43
|
+
raise ValueError("Value of lambda_ should lie in [0, 1] range")
|
|
44
|
+
|
|
45
|
+
# Make sure w0 is a row vector with unit length
|
|
46
|
+
template = np.atleast_1d(np.squeeze(template))
|
|
47
|
+
if template.ndim > 1:
|
|
48
|
+
raise ValueError(
|
|
49
|
+
f"Template weights should be a vector, got "
|
|
50
|
+
f"{template.ndim} dimensions instead"
|
|
51
|
+
)
|
|
52
|
+
template = template / norm(template)
|
|
53
|
+
template = template[np.newaxis, :]
|
|
54
|
+
|
|
55
|
+
# Split lead field matrix into in and out parts
|
|
56
|
+
L_in = leadfield[:, mask]
|
|
57
|
+
L_out = leadfield[:, ~mask]
|
|
58
|
+
|
|
59
|
+
# Solve the generalized eigenvalue problem
|
|
60
|
+
A_rat = L_out @ L_out.T
|
|
61
|
+
A_sim = L_in @ (template.T @ template) @ L_in.T
|
|
62
|
+
A = (1 - lambda_) * A_rat - lambda_ * A_sim
|
|
63
|
+
A_reg = A + regA * np.trace(A) * np.eye(*A.shape) / A.shape[0]
|
|
64
|
+
B = L_in @ L_in.T
|
|
65
|
+
B_reg = B + regB * np.trace(B) * np.eye(*B.shape) / B.shape[0]
|
|
66
|
+
[eigvals, eigvecs] = eig(A_reg, B_reg)
|
|
67
|
+
|
|
68
|
+
# Get the eigenvector that corresponds to the smallest eigenvalue
|
|
69
|
+
w = eigvecs[:, eigvals.argmin()]
|
|
70
|
+
if np.dot(w @ L_in, template.T) < 0:
|
|
71
|
+
w *= -1
|
|
72
|
+
|
|
73
|
+
return w
|
roiextract/filter.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import numpy.typing as npt
|
|
3
|
+
import mne
|
|
4
|
+
|
|
5
|
+
from typing import Collection
|
|
6
|
+
|
|
7
|
+
from .utils import (
|
|
8
|
+
_check_input,
|
|
9
|
+
data2stc,
|
|
10
|
+
get_inverse_matrix,
|
|
11
|
+
get_label_mask,
|
|
12
|
+
get_aggregation_weights,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SpatialFilter:
|
|
17
|
+
"""
|
|
18
|
+
Convenience wrapper around a NumPy array that contains the spatial filter.
|
|
19
|
+
|
|
20
|
+
Parameters:
|
|
21
|
+
-----------
|
|
22
|
+
w: array_like, shape (n_channels,)
|
|
23
|
+
The weights of the spatial filter.
|
|
24
|
+
method: str, optional
|
|
25
|
+
Can be used to store the name of the method that was used to obtain the filter.
|
|
26
|
+
method_params: dict, optional
|
|
27
|
+
Can be used to store key parameters of the method for obtaining the filter.
|
|
28
|
+
name: str, optional
|
|
29
|
+
Can be used to add a unique name to the filter (e.g., name of the ROI).
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
w: npt.ArrayLike,
|
|
35
|
+
method: str = "",
|
|
36
|
+
method_params: dict = dict(),
|
|
37
|
+
name: str = "",
|
|
38
|
+
) -> None:
|
|
39
|
+
self.w = w
|
|
40
|
+
self.method = method
|
|
41
|
+
self.method_params = method_params
|
|
42
|
+
self.name = name
|
|
43
|
+
|
|
44
|
+
def __repr__(self) -> str:
|
|
45
|
+
"""
|
|
46
|
+
Generate a short description for the filter in the following form:
|
|
47
|
+
([x] - only added if present)
|
|
48
|
+
|
|
49
|
+
<SpatialFilter | [name] | [method (method_params)] | XX channels>
|
|
50
|
+
"""
|
|
51
|
+
result = "<SpatialFilter"
|
|
52
|
+
if self.name:
|
|
53
|
+
result += f" | {self.name}"
|
|
54
|
+
if self.method:
|
|
55
|
+
params_str = [f"{k}={v}" for k, v in self.method_params.items()]
|
|
56
|
+
params_str = ", ".join(params_str)
|
|
57
|
+
params_str = f" ({params_str})" if params_str else ""
|
|
58
|
+
result += f" | {self.method}{params_str}"
|
|
59
|
+
result += f" | {self.w.size} channels>"
|
|
60
|
+
|
|
61
|
+
return result
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def from_inverse(
|
|
65
|
+
cls,
|
|
66
|
+
fwd,
|
|
67
|
+
inv,
|
|
68
|
+
label,
|
|
69
|
+
inv_method,
|
|
70
|
+
lambda2,
|
|
71
|
+
roi_method,
|
|
72
|
+
subject,
|
|
73
|
+
subjects_dir,
|
|
74
|
+
):
|
|
75
|
+
src = fwd["src"]
|
|
76
|
+
mask = get_label_mask(label, src)
|
|
77
|
+
W = get_inverse_matrix(inv, fwd, inv_method, lambda2)
|
|
78
|
+
w_agg = get_aggregation_weights(
|
|
79
|
+
roi_method, label, src, subject, subjects_dir
|
|
80
|
+
)
|
|
81
|
+
w = w_agg @ W[mask, :]
|
|
82
|
+
return cls(
|
|
83
|
+
np.atleast_1d(np.squeeze(w)),
|
|
84
|
+
method=f"{inv_method}+{roi_method}",
|
|
85
|
+
method_params=dict(lambda2=lambda2),
|
|
86
|
+
name=label.name,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def apply(self, data: npt.ArrayLike) -> np.array:
|
|
90
|
+
# TODO: check that the first dimension of data is suitable
|
|
91
|
+
return self.w @ data
|
|
92
|
+
|
|
93
|
+
def apply_raw(self, raw: mne.io.Raw | mne.io.RawArray) -> np.array:
|
|
94
|
+
return self.apply(raw.get_data())
|
|
95
|
+
|
|
96
|
+
def get_ctf(
|
|
97
|
+
self,
|
|
98
|
+
L: npt.ArrayLike,
|
|
99
|
+
mode: str = "power",
|
|
100
|
+
normalize: str | None = "sum",
|
|
101
|
+
) -> np.array:
|
|
102
|
+
_check_input("mode", mode, ["power", "amplitude"])
|
|
103
|
+
_check_input("normalize", normalize, ["norm", "max", "sum", None])
|
|
104
|
+
|
|
105
|
+
# Estimate the CTF
|
|
106
|
+
ctf = self.w @ L
|
|
107
|
+
if mode == "power":
|
|
108
|
+
ctf = ctf**2
|
|
109
|
+
|
|
110
|
+
# Normalize if needed
|
|
111
|
+
if normalize == "norm":
|
|
112
|
+
ctf /= np.linalg.norm(ctf)
|
|
113
|
+
elif normalize == "max":
|
|
114
|
+
ctf /= np.abs(ctf).max()
|
|
115
|
+
elif normalize == "sum":
|
|
116
|
+
ctf /= np.abs(ctf).sum()
|
|
117
|
+
return ctf
|
|
118
|
+
|
|
119
|
+
def get_ctf_fwd(
|
|
120
|
+
self,
|
|
121
|
+
fwd: mne.Forward,
|
|
122
|
+
mode: str = "power",
|
|
123
|
+
normalize: str = "norm",
|
|
124
|
+
subject: str | None = None,
|
|
125
|
+
) -> mne.SourceEstimate:
|
|
126
|
+
leadfield = fwd["sol"]["data"]
|
|
127
|
+
src = fwd["src"]
|
|
128
|
+
return data2stc(
|
|
129
|
+
self.get_ctf(leadfield, mode, normalize), src, subject=subject
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
def plot(self, info: mne.Info, **topomap_kwargs):
|
|
133
|
+
# Make sure that the provided info has the correct amount of channels
|
|
134
|
+
n_chans_filter = self.w.size
|
|
135
|
+
n_chans_info = len(info["ch_names"])
|
|
136
|
+
if n_chans_filter != n_chans_info:
|
|
137
|
+
raise ValueError(
|
|
138
|
+
f"The amount of channels in the provided Info object ({n_chans_info})"
|
|
139
|
+
f" does not match the length of the spatial filter ({n_chans_filter})"
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
w = np.squeeze(self.w)
|
|
143
|
+
return mne.viz.plot_topomap(w, info, **topomap_kwargs)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def apply_batch(data, filters: Collection[SpatialFilter]) -> np.array:
|
|
147
|
+
# TODO: check that all filters have the same number of channels
|
|
148
|
+
# TODO: check that the first dimension of data is suitable
|
|
149
|
+
w = np.vstack([sf.w[np.newaxis, :] for sf in filters])
|
|
150
|
+
return w @ data
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def apply_batch_raw(raw, filters: Collection[SpatialFilter]) -> np.array:
|
|
154
|
+
return apply_batch(raw.get_data(), filters)
|
roiextract/numerical.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
|
|
3
|
+
from numpy.linalg import norm
|
|
4
|
+
from scipy.optimize import minimize
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _ctf_ratio(w, L, mask):
|
|
8
|
+
# Split the leadfield into within and outside parts
|
|
9
|
+
L_in = L[:, mask]
|
|
10
|
+
L_out = L[:, ~mask]
|
|
11
|
+
|
|
12
|
+
ctf_out = np.squeeze(w @ L_out)
|
|
13
|
+
ctf_in = np.squeeze(w @ L_in)
|
|
14
|
+
|
|
15
|
+
F_out = norm(ctf_out) ** 2
|
|
16
|
+
F_in = norm(ctf_in) ** 2
|
|
17
|
+
|
|
18
|
+
# Function and its gradient for optimization
|
|
19
|
+
F = F_out / F_in
|
|
20
|
+
dF = 2 * w @ (L_out @ L_out.T - F * L_in @ L_in.T) / F_in
|
|
21
|
+
|
|
22
|
+
return F, dF
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _ctf_homogeneity(w, L, P0, mask):
|
|
26
|
+
# Use only voxels within the mask
|
|
27
|
+
L_in = L[:, mask]
|
|
28
|
+
|
|
29
|
+
# Make sure P0 is a row vector with unit length
|
|
30
|
+
P0 = np.squeeze(P0)[np.newaxis, :]
|
|
31
|
+
P0 = P0 / norm(P0)
|
|
32
|
+
|
|
33
|
+
ctf_in = np.squeeze(w @ L_in)
|
|
34
|
+
P_in = ctf_in**2
|
|
35
|
+
dotprod = np.squeeze(P_in @ P0.T)
|
|
36
|
+
L_P = L_in * np.tile(P0, (L.shape[0], 1))
|
|
37
|
+
|
|
38
|
+
F = dotprod**2 / norm(P_in) ** 2
|
|
39
|
+
dF = (
|
|
40
|
+
4
|
|
41
|
+
/ (norm(P_in) ** 2)
|
|
42
|
+
* (dotprod * ctf_in @ L_P.T - F * ctf_in**3 @ L_in.T)
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# multiply with -1 to minimize
|
|
46
|
+
return (-1) * F, (-1) * dF
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _ctf_compromise(w, L, P0, mask, lambda_):
|
|
50
|
+
F_hom, dF_hom = _ctf_homogeneity(w, L, P0, mask)
|
|
51
|
+
F_rat, dF_rat = _ctf_ratio(w, L, mask)
|
|
52
|
+
|
|
53
|
+
F = lambda_ * F_hom + (1 - lambda_) * F_rat
|
|
54
|
+
dF = lambda_ * dF_hom + (1 - lambda_) * dF_rat
|
|
55
|
+
|
|
56
|
+
return F, dF
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def minimize_with_several_guesses(fun, x0s, args, **kwargs):
|
|
60
|
+
solutions = {}
|
|
61
|
+
for x0 in x0s:
|
|
62
|
+
result = minimize(fun, x0, args=args, **kwargs)
|
|
63
|
+
solutions[result.fun] = result
|
|
64
|
+
|
|
65
|
+
best_result = min(list(solutions.keys()))
|
|
66
|
+
return solutions[best_result]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def ctf_optimize_ratio_homogeneity(
|
|
70
|
+
leadfield, template, mask, lambda_, x0s, return_scipy=False, **kwargs
|
|
71
|
+
):
|
|
72
|
+
result = minimize_with_several_guesses(
|
|
73
|
+
_ctf_compromise,
|
|
74
|
+
x0s,
|
|
75
|
+
args=(leadfield, template, mask, lambda_),
|
|
76
|
+
jac=True,
|
|
77
|
+
**kwargs,
|
|
78
|
+
)
|
|
79
|
+
if return_scipy:
|
|
80
|
+
return result
|
|
81
|
+
|
|
82
|
+
return result.x
|
roiextract/optimize.py
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
|
|
3
|
+
from functools import partial
|
|
4
|
+
|
|
5
|
+
from .analytic import ctf_optimize_ratio_similarity
|
|
6
|
+
from .filter import SpatialFilter
|
|
7
|
+
from .numerical import ctf_optimize_ratio_homogeneity
|
|
8
|
+
from .quantify import ctf_quantify
|
|
9
|
+
from .utils import (
|
|
10
|
+
get_label_mask,
|
|
11
|
+
resolve_template,
|
|
12
|
+
_check_input,
|
|
13
|
+
_report_props,
|
|
14
|
+
logger,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
INITIAL_LAMBDAS = {"rat": 0, "sim": 0.999, "hom": 0.999}
|
|
19
|
+
IS_DECREASING = {"rat": True, "sim": False, "hom": False}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def suggest_lambda(opt_func, quant_func, criteria, threshold, tol=0.001):
|
|
23
|
+
initial_lambda = INITIAL_LAMBDAS[criteria]
|
|
24
|
+
props = quant_func(w=opt_func(lambda_=initial_lambda))
|
|
25
|
+
crit_thresh = threshold * props[criteria]
|
|
26
|
+
logger.info(
|
|
27
|
+
f"Suggesting lambda to obtain at least {threshold:.2g} of max {criteria}"
|
|
28
|
+
)
|
|
29
|
+
logger.info(f"Properties (lambda={initial_lambda}): {_report_props(props)}")
|
|
30
|
+
logger.info(f"Criteria threshold: {crit_thresh:.3g}")
|
|
31
|
+
|
|
32
|
+
left, right = 0, 1
|
|
33
|
+
while right - left > tol:
|
|
34
|
+
mid = (left + right) / 2
|
|
35
|
+
props = quant_func(w=opt_func(lambda_=mid))
|
|
36
|
+
logger.info(f"Properties (lambda={mid:.3g}): {_report_props(props)}")
|
|
37
|
+
if IS_DECREASING[criteria]:
|
|
38
|
+
if props[criteria] > crit_thresh:
|
|
39
|
+
left = mid
|
|
40
|
+
else:
|
|
41
|
+
right = mid
|
|
42
|
+
else:
|
|
43
|
+
if props[criteria] > crit_thresh:
|
|
44
|
+
right = mid
|
|
45
|
+
else:
|
|
46
|
+
left = mid
|
|
47
|
+
|
|
48
|
+
return (left + right) / 2
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def ctf_optimize(
|
|
52
|
+
leadfield,
|
|
53
|
+
template,
|
|
54
|
+
mask,
|
|
55
|
+
lambda_,
|
|
56
|
+
mode="similarity",
|
|
57
|
+
criteria="rat",
|
|
58
|
+
threshold=None,
|
|
59
|
+
initial="auto",
|
|
60
|
+
tol=0.001,
|
|
61
|
+
reg=0.001,
|
|
62
|
+
quantify=False,
|
|
63
|
+
name="",
|
|
64
|
+
):
|
|
65
|
+
"""
|
|
66
|
+
Derive a spatial filter that optimizes properties of the CTF for the extracted ROI time series
|
|
67
|
+
|
|
68
|
+
Parameters
|
|
69
|
+
----------
|
|
70
|
+
leadfield: array_like
|
|
71
|
+
Lead field matrix for dipoles with fixed orientation with shape (channels, voxels).
|
|
72
|
+
template: array_like
|
|
73
|
+
Template CTF pattern for voxels with shape (voxels,). Only values within the ROI are used.
|
|
74
|
+
mask: array_like
|
|
75
|
+
Voxel mask of the region of interest with shape (voxels,). Contains ones and zeros for voxels within and
|
|
76
|
+
outside ROI, respectively.
|
|
77
|
+
lambda_: float
|
|
78
|
+
If 0, only ratio is optimized. If 1, only dot product. Values in between allow tweaking the balance between
|
|
79
|
+
optimization for dot product or ratio.
|
|
80
|
+
mode: str
|
|
81
|
+
Optimize a combination of ratio and similarity (mode="similarity") or ratio and homogeneity (mode="homogeneity").
|
|
82
|
+
reg: float
|
|
83
|
+
Regularization parameter to ensure that it is possible to calculate the inverse matrices.
|
|
84
|
+
quantify: bool
|
|
85
|
+
Whether to calculate CTF properties for the optimized spatial filter.
|
|
86
|
+
|
|
87
|
+
Returns
|
|
88
|
+
-------
|
|
89
|
+
sf: SpatialFilter
|
|
90
|
+
Spatial filter produced by the optimization.
|
|
91
|
+
props: dict, only returned if quantify=True
|
|
92
|
+
Dictionary that contains the estimates of CTF-based properties (ratio, similarity
|
|
93
|
+
and/or homogeneity).
|
|
94
|
+
|
|
95
|
+
Raises
|
|
96
|
+
------
|
|
97
|
+
ValueError
|
|
98
|
+
If provided lambda_ value is out of [0, 1] range.
|
|
99
|
+
"""
|
|
100
|
+
_check_input("mode", mode, ["similarity", "homogeneity"])
|
|
101
|
+
_check_input("initial", initial, ["auto", "ones", "reg"])
|
|
102
|
+
if lambda_ == "auto" and threshold is None:
|
|
103
|
+
raise ValueError("Threshold should be set if lambda_='auto' is used")
|
|
104
|
+
|
|
105
|
+
# Prepare the optimization and quantification functions
|
|
106
|
+
if mode == "similarity":
|
|
107
|
+
opt_func = partial(
|
|
108
|
+
ctf_optimize_ratio_similarity,
|
|
109
|
+
leadfield=leadfield,
|
|
110
|
+
template=template,
|
|
111
|
+
mask=mask,
|
|
112
|
+
regA=reg,
|
|
113
|
+
)
|
|
114
|
+
quant_func = partial(
|
|
115
|
+
ctf_quantify, leadfield=leadfield, mask=mask, w0=template
|
|
116
|
+
)
|
|
117
|
+
else:
|
|
118
|
+
# Setup the initial guess for numerical optimization
|
|
119
|
+
initial = ["ones", "reg"] if initial == "auto" else [initial]
|
|
120
|
+
x0s = []
|
|
121
|
+
if "ones" in initial:
|
|
122
|
+
x0_ones = np.ones((leadfield.shape[0],))
|
|
123
|
+
x0s.append(x0_ones)
|
|
124
|
+
if "reg" in initial:
|
|
125
|
+
# TODO: find some alternative to this formula (change lambda?)
|
|
126
|
+
regA = 0.1 if lambda_ == "auto" else np.exp(10 * lambda_**2 - 10)
|
|
127
|
+
x0_reg = ctf_optimize_ratio_similarity(
|
|
128
|
+
leadfield, template, mask, lambda_=0, regA=regA, regB=reg
|
|
129
|
+
)
|
|
130
|
+
x0s.append(x0_reg)
|
|
131
|
+
|
|
132
|
+
opt_func = partial(
|
|
133
|
+
ctf_optimize_ratio_homogeneity,
|
|
134
|
+
leadfield=leadfield,
|
|
135
|
+
template=template,
|
|
136
|
+
mask=mask,
|
|
137
|
+
x0s=x0s,
|
|
138
|
+
)
|
|
139
|
+
quant_func = partial(
|
|
140
|
+
ctf_quantify, leadfield=leadfield, mask=mask, P0=template
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# Suggest lambda if needed
|
|
144
|
+
if lambda_ == "auto":
|
|
145
|
+
lambda_ = suggest_lambda(
|
|
146
|
+
opt_func, quant_func, criteria, threshold, tol=tol
|
|
147
|
+
)
|
|
148
|
+
logger.info(
|
|
149
|
+
f"lambda={lambda_:.2g} was selected using the {threshold:2g} threshold"
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
# Optimize the filter, normalize and quantify its properties if needed
|
|
153
|
+
w_opt = opt_func(lambda_=lambda_)
|
|
154
|
+
w_opt = w_opt / np.abs(w_opt).max()
|
|
155
|
+
sf = SpatialFilter(
|
|
156
|
+
w=w_opt,
|
|
157
|
+
method="ctf_optimize",
|
|
158
|
+
method_params=dict(lambda_=lambda_),
|
|
159
|
+
name=name,
|
|
160
|
+
)
|
|
161
|
+
if quantify:
|
|
162
|
+
props = quant_func(w=w_opt)
|
|
163
|
+
return sf, props
|
|
164
|
+
|
|
165
|
+
return sf
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def ctf_optimize_label(
|
|
169
|
+
fwd,
|
|
170
|
+
label,
|
|
171
|
+
template,
|
|
172
|
+
lambda_,
|
|
173
|
+
mode="similarity",
|
|
174
|
+
criteria="rat",
|
|
175
|
+
threshold=None,
|
|
176
|
+
initial="auto",
|
|
177
|
+
tol=0.001,
|
|
178
|
+
reg=0.001,
|
|
179
|
+
quantify=False,
|
|
180
|
+
name=None,
|
|
181
|
+
):
|
|
182
|
+
# Extract data from Forward and Label
|
|
183
|
+
leadfield = fwd["sol"]["data"]
|
|
184
|
+
src = fwd["src"]
|
|
185
|
+
if name is None:
|
|
186
|
+
name = label.name
|
|
187
|
+
|
|
188
|
+
# Create a binary mask for the ROI
|
|
189
|
+
mask = get_label_mask(label, src)
|
|
190
|
+
|
|
191
|
+
# Support pre-defined options for template weights
|
|
192
|
+
template = resolve_template(template, label, src)
|
|
193
|
+
|
|
194
|
+
# Optimize the filter and quantify its properties if needed
|
|
195
|
+
return ctf_optimize(
|
|
196
|
+
leadfield,
|
|
197
|
+
template,
|
|
198
|
+
mask,
|
|
199
|
+
lambda_,
|
|
200
|
+
mode=mode,
|
|
201
|
+
criteria=criteria,
|
|
202
|
+
threshold=threshold,
|
|
203
|
+
initial=initial,
|
|
204
|
+
tol=tol,
|
|
205
|
+
reg=reg,
|
|
206
|
+
quantify=quantify,
|
|
207
|
+
name=name,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def rec_optimize(
|
|
212
|
+
cov_matrix,
|
|
213
|
+
inverse,
|
|
214
|
+
template,
|
|
215
|
+
mask,
|
|
216
|
+
lambda_,
|
|
217
|
+
mode="similarity",
|
|
218
|
+
criteria="rat",
|
|
219
|
+
threshold=None,
|
|
220
|
+
tol=0.001,
|
|
221
|
+
reg=0.001,
|
|
222
|
+
quantify=False,
|
|
223
|
+
name="",
|
|
224
|
+
):
|
|
225
|
+
return ctf_optimize(
|
|
226
|
+
cov_matrix.T @ inverse,
|
|
227
|
+
template,
|
|
228
|
+
mask,
|
|
229
|
+
lambda_,
|
|
230
|
+
mode=mode,
|
|
231
|
+
criteria=criteria,
|
|
232
|
+
threshold=threshold,
|
|
233
|
+
tol=tol,
|
|
234
|
+
reg=reg,
|
|
235
|
+
quantify=quantify,
|
|
236
|
+
name=name,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def rec_optimize_label(
|
|
241
|
+
cov_matrix,
|
|
242
|
+
fwd,
|
|
243
|
+
inv,
|
|
244
|
+
label,
|
|
245
|
+
template,
|
|
246
|
+
lambda_,
|
|
247
|
+
mode="similarity",
|
|
248
|
+
criteria="rat",
|
|
249
|
+
threshold=None,
|
|
250
|
+
tol=0.001,
|
|
251
|
+
reg=0.001,
|
|
252
|
+
quantify=False,
|
|
253
|
+
name=None,
|
|
254
|
+
**inv_kwargs,
|
|
255
|
+
):
|
|
256
|
+
from mne.minimum_norm.resolution_matrix import (
|
|
257
|
+
_get_matrix_from_inverse_operator,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
# Extract data from Forward and Label
|
|
261
|
+
src = fwd["src"]
|
|
262
|
+
if name is None:
|
|
263
|
+
name = label.name
|
|
264
|
+
|
|
265
|
+
# Extract data from InverseOperator
|
|
266
|
+
inverse = _get_matrix_from_inverse_operator(inv, fwd, **inv_kwargs)
|
|
267
|
+
|
|
268
|
+
# Create a binary mask for the ROI
|
|
269
|
+
mask = get_label_mask(label, src)
|
|
270
|
+
|
|
271
|
+
# Support pre-defined options for template weights
|
|
272
|
+
template = resolve_template(template, label, src)
|
|
273
|
+
|
|
274
|
+
# Optimize the filter and quantify its properties if needed
|
|
275
|
+
return rec_optimize(
|
|
276
|
+
cov_matrix,
|
|
277
|
+
inverse,
|
|
278
|
+
template,
|
|
279
|
+
mask,
|
|
280
|
+
lambda_,
|
|
281
|
+
mode=mode,
|
|
282
|
+
criteria=criteria,
|
|
283
|
+
threshold=threshold,
|
|
284
|
+
tol=tol,
|
|
285
|
+
reg=reg,
|
|
286
|
+
quantify=quantify,
|
|
287
|
+
name=name,
|
|
288
|
+
)
|
roiextract/quantify.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
|
|
3
|
+
from numpy.linalg import norm
|
|
4
|
+
|
|
5
|
+
from .utils import resolve_template, get_label_mask
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def ctf_ratio(w, L, mask, source_mask=None):
|
|
9
|
+
"""
|
|
10
|
+
Calculates ratio of CTF outside/within ROI
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
# Take into account only a subset of sources if source mask is provided
|
|
14
|
+
L_all = L.copy()
|
|
15
|
+
if source_mask is not None:
|
|
16
|
+
mask = np.logical_and(mask, source_mask)
|
|
17
|
+
L_all = L[:, source_mask]
|
|
18
|
+
|
|
19
|
+
# TODO: check that the mask contains at least one source
|
|
20
|
+
|
|
21
|
+
# Split lead field matrix into in and out parts
|
|
22
|
+
L_in = L[:, mask]
|
|
23
|
+
|
|
24
|
+
ctf_all = np.squeeze(w @ L_all)
|
|
25
|
+
ctf_in = np.squeeze(w @ L_in)
|
|
26
|
+
|
|
27
|
+
# Adjusted value in the [0, 1] range
|
|
28
|
+
return norm(ctf_in) / norm(ctf_all)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def ctf_similarity(w, L, w0, mask, source_mask=None):
|
|
32
|
+
"""
|
|
33
|
+
Cosine similarity of desired and constructed CTFs within ROI
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
# Take into account only a subset of sources if source mask is provided
|
|
37
|
+
if source_mask is not None:
|
|
38
|
+
within_mask = source_mask[mask]
|
|
39
|
+
mask = np.logical_and(mask, source_mask)
|
|
40
|
+
assert within_mask.sum() > 0
|
|
41
|
+
|
|
42
|
+
# Use only voxels within the mask
|
|
43
|
+
L_in = L[:, mask]
|
|
44
|
+
|
|
45
|
+
# TODO: check that the mask contains at least one source
|
|
46
|
+
assert mask.sum() > 0
|
|
47
|
+
|
|
48
|
+
# Make sure w0 is a row vector with unit length
|
|
49
|
+
if source_mask is not None:
|
|
50
|
+
w0 = w0[within_mask]
|
|
51
|
+
if w0.size > 1:
|
|
52
|
+
w0 = np.squeeze(w0)
|
|
53
|
+
w0 = w0[np.newaxis, :]
|
|
54
|
+
w0 = w0 / norm(w0)
|
|
55
|
+
|
|
56
|
+
dotprod = np.squeeze(w @ L_in @ w0.T)
|
|
57
|
+
ctf = np.squeeze(w @ L_in)
|
|
58
|
+
|
|
59
|
+
# Adjusted value in the [0, 1] range
|
|
60
|
+
return np.abs(dotprod / norm(ctf))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def ctf_homogeneity(w, L, P0, mask, source_mask=None):
|
|
64
|
+
"""
|
|
65
|
+
Homogeneity of the CTF within ROI
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
# Take into account only a subset of sources if source mask is provided
|
|
69
|
+
if source_mask is not None:
|
|
70
|
+
within_mask = source_mask[mask]
|
|
71
|
+
mask = np.logical_and(mask, source_mask)
|
|
72
|
+
assert within_mask.sum() > 0
|
|
73
|
+
|
|
74
|
+
# TODO: check that the mask contains at least one source
|
|
75
|
+
assert mask.sum() > 0
|
|
76
|
+
|
|
77
|
+
# Use only voxels within the mask
|
|
78
|
+
L_in = L[:, mask]
|
|
79
|
+
|
|
80
|
+
# Make sure P0 is a row vector with unit length
|
|
81
|
+
if source_mask is not None:
|
|
82
|
+
P0 = P0[within_mask]
|
|
83
|
+
if P0.size > 1:
|
|
84
|
+
P0 = np.squeeze(P0)
|
|
85
|
+
P0 = P0[np.newaxis, :]
|
|
86
|
+
P0 = P0 / norm(P0)
|
|
87
|
+
|
|
88
|
+
ctf2 = (w @ L_in) ** 2
|
|
89
|
+
dotprod = np.squeeze(ctf2 @ P0.T)
|
|
90
|
+
|
|
91
|
+
# Adjusted value in the [0, 1] range
|
|
92
|
+
return np.abs(dotprod / norm(ctf2))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def ctf_quantify(w, leadfield, mask, w0=None, P0=None, source_mask=None):
|
|
96
|
+
"""
|
|
97
|
+
Calculate similarity within ROI and in/out ratio for a given filter
|
|
98
|
+
|
|
99
|
+
Parameters
|
|
100
|
+
----------
|
|
101
|
+
w: array_like
|
|
102
|
+
Spatial filter that needs to be quantified in terms of CTF.
|
|
103
|
+
L: array_like
|
|
104
|
+
Lead field matrix for dipoles with fixed orientation with shape (channels, voxels).
|
|
105
|
+
mask: array_like
|
|
106
|
+
Voxel mask of the region of interest with shape (voxels,). Contains ones and zeros for voxels within and
|
|
107
|
+
outside ROI, respectively.
|
|
108
|
+
w0: array_like
|
|
109
|
+
Template CTF pattern for voxels within ROI with shape (voxels_roi,).
|
|
110
|
+
P0: array_like
|
|
111
|
+
Template power contribution for voxels within ROI with shape (voxels_roi,).
|
|
112
|
+
source_mask: array_like
|
|
113
|
+
Voxel mask with shape (voxels,). Ones correspond to all voxels that should be
|
|
114
|
+
considered when evaluating CTF properties.
|
|
115
|
+
|
|
116
|
+
Returns
|
|
117
|
+
-------
|
|
118
|
+
result: a dictionary with the estimated CTF properties
|
|
119
|
+
'rat': float
|
|
120
|
+
Ratio of CTF within ROI and total CTF, lies in [0, 1].
|
|
121
|
+
'sim': float
|
|
122
|
+
Similarity between the actual CTF and the template CTF pattern, lies in [0, 1]. It is only returned if w0 was provided.
|
|
123
|
+
'hom': float
|
|
124
|
+
Homogeneity of the power contributions of voxels within the ROI, lies in [0, 1]. It is only returned if P0 was provided.
|
|
125
|
+
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
result = dict()
|
|
129
|
+
result["rat"] = ctf_ratio(w, leadfield, mask, source_mask=source_mask)
|
|
130
|
+
if w0 is not None:
|
|
131
|
+
result["sim"] = ctf_similarity(
|
|
132
|
+
w, leadfield, w0, mask, source_mask=source_mask
|
|
133
|
+
)
|
|
134
|
+
if P0 is not None:
|
|
135
|
+
result["hom"] = ctf_homogeneity(
|
|
136
|
+
w, leadfield, P0, mask, source_mask=source_mask
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
return result
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def ctf_quantify_label(w, fwd, label, w0=None, P0=None, source_mask=None):
|
|
143
|
+
# Extract data from Forward
|
|
144
|
+
leadfield = fwd["sol"]["data"]
|
|
145
|
+
src = fwd["src"]
|
|
146
|
+
|
|
147
|
+
# Create a binary mask for the ROI
|
|
148
|
+
mask = get_label_mask(label, src)
|
|
149
|
+
|
|
150
|
+
# Support pre-defined options for template weights
|
|
151
|
+
if w0 is not None:
|
|
152
|
+
w0 = resolve_template(w0, label, src)
|
|
153
|
+
if P0 is not None:
|
|
154
|
+
P0 = resolve_template(P0, label, src)
|
|
155
|
+
|
|
156
|
+
return ctf_quantify(
|
|
157
|
+
w, leadfield, mask, w0=w0, P0=P0, source_mask=source_mask
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def rec_quantify(w, cov_matrix, inverse, template, mask, source_mask=None):
|
|
162
|
+
return ctf_quantify(
|
|
163
|
+
w, cov_matrix.T @ inverse, template, mask, source_mask=source_mask
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def rec_quantify_label(w, fwd, label, template, source_mask=None):
|
|
168
|
+
# Extract data from Forward
|
|
169
|
+
leadfield = fwd["sol"]["data"]
|
|
170
|
+
src = fwd["src"]
|
|
171
|
+
|
|
172
|
+
# Create a binary mask for the ROI
|
|
173
|
+
mask = get_label_mask(label, src)
|
|
174
|
+
|
|
175
|
+
# Support pre-defined options for template weights
|
|
176
|
+
template = resolve_template(template, label, src)
|
|
177
|
+
|
|
178
|
+
return ctf_quantify(w, leadfield, template, mask, source_mask=source_mask)
|
roiextract/utils.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import numpy as np
|
|
3
|
+
import mne
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger("roiextract")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _check_input(param, value, allowed_values):
|
|
10
|
+
if value not in allowed_values:
|
|
11
|
+
raise ValueError(f"Value {value} is not supported for {param}")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _report_props(props):
|
|
15
|
+
return ", ".join([f"{k}={v:.2g}" for k, v in props.items()])
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_label_mask(label, src):
|
|
19
|
+
vertno = [s["vertno"] for s in src]
|
|
20
|
+
nvert = [len(vn) for vn in vertno]
|
|
21
|
+
if label.hemi == "lh":
|
|
22
|
+
this_vertices = np.intersect1d(vertno[0], label.vertices)
|
|
23
|
+
vert = np.searchsorted(vertno[0], this_vertices)
|
|
24
|
+
elif label.hemi == "rh":
|
|
25
|
+
this_vertices = np.intersect1d(vertno[1], label.vertices)
|
|
26
|
+
vert = nvert[0] + np.searchsorted(vertno[1], this_vertices)
|
|
27
|
+
else:
|
|
28
|
+
raise ValueError("label %s has invalid hemi" % label.name)
|
|
29
|
+
|
|
30
|
+
mask = np.zeros((sum(nvert),), dtype=int)
|
|
31
|
+
mask[vert] = 1
|
|
32
|
+
return mask > 0
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def resolve_template(template, label, src):
|
|
36
|
+
if isinstance(template, str):
|
|
37
|
+
from mne.label import label_sign_flip
|
|
38
|
+
|
|
39
|
+
_check_input("template", template, ["mean_flip", "mean"])
|
|
40
|
+
signflip = label_sign_flip(label, src)[np.newaxis, :]
|
|
41
|
+
|
|
42
|
+
if template == "mean_flip":
|
|
43
|
+
return signflip
|
|
44
|
+
if template == "mean":
|
|
45
|
+
return np.ones((1, signflip.size))
|
|
46
|
+
|
|
47
|
+
return template
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def data2stc(data, src, subject=None):
|
|
51
|
+
vertno = [s["vertno"] for s in src]
|
|
52
|
+
return mne.SourceEstimate(
|
|
53
|
+
data=data, vertices=vertno, tmin=0, tstep=0.01, subject=subject
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def get_inverse_matrix(inv, fwd, method, lambda2):
|
|
58
|
+
# TODO: get rid of private dependency
|
|
59
|
+
from mne.minimum_norm.resolution_matrix import (
|
|
60
|
+
_get_matrix_from_inverse_operator,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
return _get_matrix_from_inverse_operator(inv, fwd, method, lambda2)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def get_aggregation_weights(method, label, src, subject, subjects_dir):
|
|
67
|
+
_check_input("method", method, ["mean", "mean_flip", "centroid"])
|
|
68
|
+
if method in ["mean", "mean_flip"]:
|
|
69
|
+
return resolve_template(method, label, src)
|
|
70
|
+
|
|
71
|
+
# find the center of mass
|
|
72
|
+
hemi_idx = 0 if label.hemi == "lh" else 1
|
|
73
|
+
label_vertices = label.get_vertices_used(src[hemi_idx]["vertno"])
|
|
74
|
+
centroid_idx = label.center_of_mass(
|
|
75
|
+
subject=subject, restrict_vertices=src, subjects_dir=subjects_dir
|
|
76
|
+
)
|
|
77
|
+
return (label_vertices == centroid_idx).astype(int)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: roiextract
|
|
3
|
+
Version: 0.0.2
|
|
4
|
+
Summary: Data-independent and data-driven optimization of extraction of ROI time series based on M/EEG
|
|
5
|
+
Project-URL: Homepage, https://github.com/ctrltz/roiextract
|
|
6
|
+
Project-URL: Bug Tracker, https://github.com/ctrltz/roiextract/issues
|
|
7
|
+
Author-email: Nikolai Kapralov <kapralov@cbs.mpg.de>
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Requires-Python: >=3.7
|
|
13
|
+
Requires-Dist: mne
|
|
14
|
+
Requires-Dist: numpy
|
|
15
|
+
Requires-Dist: scipy
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# ROIextract
|
|
19
|
+
|
|
20
|
+
Optimization of extraction of ROI time series based on the cross-talk function (CTF) or source reconstruction of spatial patterns (REC). **Work in progress!**
|
|
21
|
+
|
|
22
|
+
## Usage
|
|
23
|
+
|
|
24
|
+
Obtain a spatial filter that optimizes CTF properties:
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from roiextract import ctf_optimize_label
|
|
28
|
+
|
|
29
|
+
sf = ctf_optimize_label(fwd, label, template, lambda_)
|
|
30
|
+
|
|
31
|
+
sf, props = ctf_optimize_label(fwd, label, template, lambda_, quantify=True)
|
|
32
|
+
|
|
33
|
+
sf = ctf_optimize_label(fwd, label, template, lambda_='auto', threshold=0.95)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Plot the filter as a topomap:
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
sf.plot(info)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Apply it to the data to obtain the time course of activity in the ROI/label:
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
label_tc = sf.apply(data)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Estimate the CTF for the filter:
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
ctf = sf.get_ctf_fwd(fwd) # ctf is an instance of mne.SourceEstimate
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Plot the CTF on the brain surface:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
ctf.plot()
|
|
58
|
+
```
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
roiextract/__init__.py,sha256=_GA_t1v_b8OKJtvhKOcvCbl4A3dcJ4N_AHmPJCW2-pc,159
|
|
2
|
+
roiextract/analytic.py,sha256=WuiQmHZ06Mvtd0MrcasbXiaukDJi1odprJM8oS-YLEI,2390
|
|
3
|
+
roiextract/filter.py,sha256=uVRK0thESf8GsPYZpx9Ljghm_Vt_fI11u4TWwLRT3D4,4661
|
|
4
|
+
roiextract/numerical.py,sha256=BtrBrJGf4d9-vcMLQb_LZbbdGjxvZPDZ1jg3jRzdPhA,1971
|
|
5
|
+
roiextract/optimize.py,sha256=fy8GpCUWi5yGq0rrq5jQJAPMN__DwG6AhEW9FYRykXA,7851
|
|
6
|
+
roiextract/quantify.py,sha256=jFwtGvzL_fvQxXAwRu99Mmfmz0VG7rkhILij1k4K8e0,5389
|
|
7
|
+
roiextract/utils.py,sha256=wTcEv0ewTS17448BThnDuFbrjhdxNCebIFEayd6IMfc,2367
|
|
8
|
+
roiextract-0.0.2.dist-info/METADATA,sha256=_m9pKYx3so-vruvq2dYXZa29yjNAwpsEZYyJdq88yRM,1502
|
|
9
|
+
roiextract-0.0.2.dist-info/WHEEL,sha256=zEMcRr9Kr03x1ozGwg5v9NQBKn3kndp6LSoSlVg-jhU,87
|
|
10
|
+
roiextract-0.0.2.dist-info/licenses/LICENSE,sha256=EgvrKcYLEbvwsR_bnQY95QeSwsP2Y0Q_azHcXvri0XM,1073
|
|
11
|
+
roiextract-0.0.2.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2023 Nikolai Kapralov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|