Skip to content

Commit 6fd9dc7

Browse files
Merge branch 'OSIPI:main' into fix-issue-86
2 parents aa60e49 + 79794b6 commit 6fd9dc7

12 files changed

Lines changed: 704 additions & 994 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Publishes to TestPyPI when you push a tag starting with "test-v"
2+
on:
3+
push:
4+
tags:
5+
- "test-v*"
6+
7+
# Publishes to real PyPI when you create a proper GitHub release
8+
release:
9+
types: [published]
10+
11+
jobs:
12+
publish:
13+
runs-on: ubuntu-latest
14+
permissions:
15+
id-token: write
16+
steps:
17+
- uses: actions/checkout@v4
18+
- uses: actions/setup-python@v5
19+
with:
20+
python-version: "3.12"
21+
- run: pip install build
22+
- run: python -m build
23+
24+
- name: Publish to TestPyPI
25+
if: startsWith(github.ref, 'refs/tags/test-v')
26+
uses: pypa/gh-action-pypi-publish@release/v1
27+
with:
28+
repository-url: https://test.pypi.org/legacy/
29+
30+
- name: Publish to PyPI
31+
if: github.event_name == 'release'
32+
uses: pypa/gh-action-pypi-publish@release/v1

CITATION.cff

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
cff-version: 1.2.0
2+
message: "If you use this software, please cite it as below."
3+
title: "OSIPI TF2.4 IVIM-MRI Code Collection"
4+
version: 0.1.0
5+
date-released: 2026
6+
license: Apache-2.0
7+
repository-code: "https://github.com/OSIPI/TF2.4_IVIM-MRI_CodeCollection"
8+
authors:
9+
- family-names: Jalnefjord
10+
given-names: Oscar
11+
orcid: "https://orcid.org/XXXX-XXXX-XXXX-XXXX"
12+
affiliation: "University of Gothenburg"
13+
- family-names: Rashid
14+
given-names: Ivan A.
15+
affiliation: "Lund University"
16+
- family-names: Kuppens
17+
given-names: Daan
18+
affiliation: "Amsterdam University Medical Center"
19+
- family-names: van der Thiel
20+
given-names: Merel M.
21+
affiliation: "Maastricht University Medical Center"
22+
- family-names: van Houdt
23+
given-names: Petra J.
24+
affiliation: "Netherlands Cancer Institute"
25+
- family-names: Voorter
26+
given-names: Paulien H.M.
27+
affiliation: "Maastricht University Medical Center"
28+
- family-names: Peterson
29+
given-names: Eric T.
30+
affiliation: "SRI International"
31+
- family-names: Gurney-Champion
32+
given-names: Oliver J.
33+
affiliation: "Amsterdam University Medical Center"
34+
preferred-citation:
35+
type: article
36+
title: "An open-source code repository for intravoxel incoherent motion analysis: ISMRM Open Science Initiative for Perfusion Imaging (OSIPI)"
37+
authors:
38+
- family-names: Jalnefjord
39+
given-names: Oscar
40+
- family-names: Rashid
41+
given-names: Ivan A.
42+
- family-names: Kuppens
43+
given-names: Daan
44+
- family-names: van der Thiel
45+
given-names: Merel M.
46+
- family-names: van Houdt
47+
given-names: Petra J.
48+
- family-names: Voorter
49+
given-names: Paulien H.M.
50+
- family-names: Peterson
51+
given-names: Eric T.
52+
- family-names: Gurney-Champion
53+
given-names: Oliver J.
54+
journal: "Magnetic Resonance in Medicine"
55+
year: 2026
56+
doi: "10.XXXX/XXXXXXX" # fill in once published

phantoms/MR_XCAT_qMRI/sim_ivim_sig.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -451,15 +451,29 @@ def parse_bvalues_file(file_path):
451451

452452
voxel_selector_fraction = 0.5
453453
D, f, Ds = contrast_curve_calc()
454-
ignore = np.isnan(D)
454+
ignore = np.logical_or(np.logical_or(np.isnan(D),f<0.03),f>0.97)
455455
generic_data = {}
456+
seen_combinations = set()
457+
456458
for level, name in legend.items():
457459
if len(ignore) > level and ignore[level]:
458460
continue
459461
selector = XCAT == level
460462
voxels = sig[selector]
461463
if len(voxels) < 1:
462464
continue
465+
D_val = np.median(Dim[selector], axis=0)
466+
f_val = np.median(fim[selector], axis=0)
467+
Dp_val = np.median(Dpim[selector], axis=0)
468+
469+
combo = (tuple(np.atleast_1d(D_val)),
470+
tuple(np.atleast_1d(f_val)),
471+
tuple(np.atleast_1d(Dp_val)))
472+
473+
if combo in seen_combinations:
474+
continue
475+
476+
seen_combinations.add(combo)
463477
signals = np.squeeze(voxels[int(voxels.shape[0] * voxel_selector_fraction)]).tolist()
464478
generic_data[name] = {
465479
'noise': noise,

pyproject.toml

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
[build-system]
2+
requires = ["setuptools>=61", "wheel"]
3+
build-backend = "setuptools.build_meta"
4+
5+
[project]
6+
name = "osipi-ivim"
7+
version = "0.1.0"
8+
description = "OSIPI TF2.4 IVIM-MRI Code Collection"
9+
license = {text = "Apache-2.0"}
10+
readme = "README.md"
11+
requires-python = ">=3.11"
12+
authors = [
13+
{name = "Oscar Jalnefjord", email = "oscar.jalnefjord@gu.se"},
14+
{name = "Ivan A. Rashid"},
15+
{name = "Daan Kuppens"},
16+
{name = "Merel M. van der Thiel"},
17+
{name = "Petra J. van Houdt"},
18+
{name = "Paulien H.M. Voorter"},
19+
{name = "Eric T. Peterson"},
20+
{name = "Oliver J. Gurney-Champion", email = "o.j.gurney-champion@amsterdamumc.nl"},
21+
]
22+
dependencies = [
23+
"numpy",
24+
"scipy",
25+
"nibabel",
26+
"torch",
27+
"torchio",
28+
"joblib",
29+
"dipy",
30+
"cvxpy",
31+
"nlopt",
32+
"tqdm",
33+
"pandas",
34+
"statsmodels",
35+
"ivimnet",
36+
"super-ivim-dc>1.0.0",
37+
"zenodo-get",
38+
]
39+
40+
[project.optional-dependencies]
41+
test = [
42+
"pytest",
43+
"pytest-json-report",
44+
]
45+
docs = [
46+
"sphinx",
47+
"sphinx_rtd_theme",
48+
]
49+
plot = [
50+
"matplotlib",
51+
"scienceplots",
52+
]
53+
all = [
54+
"osipi-ivim[test,docs,plot]",
55+
]
56+
57+
[project.urls]
58+
Repository = "https://github.com/OSIPI/TF2.4_IVIM-MRI_CodeCollection"
59+
60+
[tool.setuptools.packages.find]
61+
where = ["src"]

src/original/DT_IIITN/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# WLS IVIM fitting by Devguru Tiwari, IIIT Nagpur
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
"""
2+
Weighted Least Squares (WLS) / Robust Linear Model (RLM) IVIM fitting.
3+
4+
Author: Devguru Tiwari, IIIT Nagpur
5+
Date: 2026-03-01
6+
7+
Implements a segmented approach for IVIM parameter estimation:
8+
1. Estimate D from high b-values using weighted/robust linear regression on log-signal
9+
2. Estimate f from the intercept of the Step 1 fit
10+
3. Estimate D* from residuals at low b-values using weighted/robust linear regression
11+
12+
Two regression methods are available:
13+
- WLS: Weighted Linear Least Squares with Veraart weights (w = S^2)
14+
- RLM: Robust Linear Model using Huber's T norm (statsmodels)
15+
16+
Reference:
17+
Veraart, J. et al. (2013). "Weighted linear least squares estimation of
18+
diffusion MRI parameters: strengths, limitations, and pitfalls."
19+
NeuroImage, 81, 335-346.
20+
DOI: 10.1016/j.neuroimage.2013.05.028
21+
22+
Requirements:
23+
numpy
24+
statsmodels (only for method="RLM")
25+
"""
26+
27+
import numpy as np
28+
import warnings
29+
30+
31+
def _weighted_linreg(x, y, weights):
32+
"""Fast weighted linear regression: y = a + b*x.
33+
34+
Uses Veraart et al. (2013) approach with weights = S^2.
35+
36+
Args:
37+
x: 1D array, independent variable.
38+
y: 1D array, dependent variable.
39+
weights: 1D array, weights for each observation.
40+
41+
Returns:
42+
(intercept, slope) tuple.
43+
"""
44+
W = np.diag(weights)
45+
X = np.column_stack([np.ones_like(x), x])
46+
# Weighted normal equations: (X^T W X) beta = X^T W y
47+
XtW = X.T @ W
48+
beta = np.linalg.solve(XtW @ X, XtW @ y)
49+
return beta[0], beta[1] # intercept, slope
50+
51+
52+
def _rlm_linreg(x, y):
53+
"""Robust linear regression using statsmodels RLM with Huber's T norm.
54+
55+
RLM down-weights outlier observations via iteratively reweighted least
56+
squares (IRLS), making the fit resistant to corrupted/noisy voxels.
57+
58+
Args:
59+
x: 1D array, independent variable.
60+
y: 1D array, dependent variable.
61+
62+
Returns:
63+
(intercept, slope) tuple.
64+
"""
65+
import statsmodels.api as sm
66+
X = sm.add_constant(x)
67+
model = sm.RLM(y, X, M=sm.robust.norms.HuberT())
68+
result = model.fit()
69+
return result.params[0], result.params[1] # intercept, slope
70+
71+
72+
def wls_ivim_fit(bvalues, signal, cutoff=200, method="WLS"):
73+
"""
74+
IVIM fit using WLS or RLM (segmented approach).
75+
76+
Step 1: Fit D from high b-values on log-signal.
77+
Step 2: Fit D* from residuals at low b-values.
78+
79+
Args:
80+
bvalues (array-like): 1D array of b-values (s/mm²).
81+
signal (array-like): 1D array of signal intensities (will be normalized).
82+
cutoff (float): b-value threshold separating D from D* fitting.
83+
Default: 200 s/mm².
84+
method (str): Regression method to use.
85+
- "WLS": Weighted Least Squares with Veraart S² weights (default).
86+
- "RLM": Robust Linear Model with Huber's T norm (statsmodels).
87+
88+
Returns:
89+
tuple: (D, f, Dp) where
90+
D (float): True diffusion coefficient (mm²/s).
91+
f (float): Perfusion fraction (0-1).
92+
Dp (float): Pseudo-diffusion coefficient (mm²/s).
93+
"""
94+
method = method.upper()
95+
if method not in ("WLS", "RLM"):
96+
raise ValueError(f"Unknown method '{method}'. Use 'WLS' or 'RLM'.")
97+
98+
bvalues = np.array(bvalues, dtype=float)
99+
signal = np.array(signal, dtype=float)
100+
101+
# Normalize signal to S(b=0)
102+
s0_vals = signal[bvalues == 0]
103+
if len(s0_vals) == 0 or np.mean(s0_vals) <= 0:
104+
return 0.0, 0.0, 0.0
105+
s0 = np.mean(s0_vals)
106+
signal = signal / s0
107+
108+
try:
109+
# ── Step 1: Estimate D from high b-values ─────────────────────
110+
# At high b, perfusion component ≈ 0, so:
111+
# S(b) ≈ (1 - f) * exp(-b * D)
112+
# ln(S(b)) = ln(1 - f) - b * D
113+
high_mask = bvalues >= cutoff
114+
b_high = bvalues[high_mask]
115+
s_high = signal[high_mask]
116+
117+
# Guard against zero/negative signal values
118+
s_high = np.maximum(s_high, 1e-8)
119+
log_s = np.log(s_high)
120+
121+
if method == "WLS":
122+
# Veraart weights: w = S^2 (corrects for noise in log-domain)
123+
weights_high = s_high ** 2
124+
intercept, D = _weighted_linreg(-b_high, log_s, weights_high)
125+
else:
126+
# RLM: robust regression, no explicit weights needed
127+
intercept, D = _rlm_linreg(-b_high, log_s)
128+
129+
# Extract f from intercept: intercept = ln(1 - f)
130+
f = 1.0 - np.exp(intercept)
131+
132+
# Clamp to physically meaningful ranges
133+
D = np.clip(D, 0, 0.005)
134+
f = np.clip(f, 0, 1)
135+
136+
# ── Step 2: Estimate D* from low b-value residuals ────────────
137+
# Subtract the diffusion component:
138+
# residual(b) = S(b) - (1 - f) * exp(-b * D)
139+
# ≈ f * exp(-b * D*)
140+
# ln(residual) = ln(f) - b * D*
141+
residual = signal - (1 - f) * np.exp(-bvalues * D)
142+
143+
low_mask = (bvalues < cutoff) & (bvalues > 0)
144+
b_low = bvalues[low_mask]
145+
r_low = residual[low_mask]
146+
147+
# Guard against zero/negative residuals
148+
r_low = np.maximum(r_low, 1e-8)
149+
log_r = np.log(r_low)
150+
151+
if len(b_low) >= 2:
152+
if method == "WLS":
153+
weights_low = r_low ** 2
154+
_, Dp = _weighted_linreg(-b_low, log_r, weights_low)
155+
else:
156+
_, Dp = _rlm_linreg(-b_low, log_r)
157+
Dp = np.clip(Dp, 0.005, 0.2)
158+
else:
159+
Dp = 0.01 # fallback
160+
161+
# Ensure D* > D (by convention)
162+
if Dp < D:
163+
D, Dp = Dp, D
164+
f = 1 - f
165+
166+
return D, f, Dp
167+
168+
except Exception:
169+
# If fit fails, return zeros (consistent with other algorithms)
170+
return 0.0, 0.0, 0.0

0 commit comments

Comments
 (0)