ulfsynth 0.1.0__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.
- ulfsynth/__init__.py +10 -0
- ulfsynth/cli.py +85 -0
- ulfsynth/enhance.py +245 -0
- ulfsynth/simulate.py +177 -0
- ulfsynth-0.1.0.dist-info/METADATA +231 -0
- ulfsynth-0.1.0.dist-info/RECORD +10 -0
- ulfsynth-0.1.0.dist-info/WHEEL +5 -0
- ulfsynth-0.1.0.dist-info/entry_points.txt +2 -0
- ulfsynth-0.1.0.dist-info/licenses/LICENSE +21 -0
- ulfsynth-0.1.0.dist-info/top_level.txt +1 -0
ulfsynth/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ULF-Synth: Physics-Guided Ultra-Low-Field MRI Enhancement & Simulation.
|
|
3
|
+
|
|
4
|
+
Submodules:
|
|
5
|
+
simulate — Synthesize realistic ULF MRI from high-field NIfTI volumes
|
|
6
|
+
enhance — Enhance ULF MRI using pretrained restoration models
|
|
7
|
+
cli — Command-line interface (ulfsynth simulate, ulfsynth enhance)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
ulfsynth/cli.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Command-line interface for ULF-Synth.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
ulfsynth simulate input output [--seed N] [--quiet]
|
|
6
|
+
ulfsynth enhance input output [--device DEVICE] [--quiet]
|
|
7
|
+
ulfsynth download-weights [--force]
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import os
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def add_simulate_parser(subparsers):
|
|
15
|
+
p = subparsers.add_parser("simulate", help="Synthesize ULF MRI from HF volumes")
|
|
16
|
+
p.add_argument("input", help="Path to .nii/.nii.gz file or folder of NIfTI files")
|
|
17
|
+
p.add_argument("output", help="Output file path or folder")
|
|
18
|
+
p.add_argument("--seed", type=int, default=None, help="Random seed")
|
|
19
|
+
p.add_argument("--quiet", action="store_true", help="Suppress per-file output")
|
|
20
|
+
p.set_defaults(func=_run_simulate)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _run_simulate(args):
|
|
24
|
+
from ulfsynth.simulate import simulate_file, simulate_folder
|
|
25
|
+
verbose = not args.quiet
|
|
26
|
+
if os.path.isdir(args.input):
|
|
27
|
+
simulate_folder(args.input, args.output, seed=args.seed, verbose=verbose)
|
|
28
|
+
elif os.path.isfile(args.input):
|
|
29
|
+
simulate_file(args.input, args.output, seed=args.seed, verbose=verbose)
|
|
30
|
+
else:
|
|
31
|
+
raise FileNotFoundError(f"Input not found: {args.input}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def add_enhance_parser(subparsers):
|
|
35
|
+
p = subparsers.add_parser("enhance", help="Enhance ULF MRI using pretrained model")
|
|
36
|
+
p.add_argument("input", help="Path to .nii/.nii.gz file or folder of NIfTI files")
|
|
37
|
+
p.add_argument("output", help="Output file path or folder")
|
|
38
|
+
p.add_argument("--device", type=str, default="cuda",
|
|
39
|
+
choices=["cuda", "cpu", "mps"],
|
|
40
|
+
help="Device for inference (default: cuda)")
|
|
41
|
+
p.add_argument("--quiet", action="store_true", help="Suppress progress output")
|
|
42
|
+
p.set_defaults(func=_run_enhance)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _run_enhance(args):
|
|
46
|
+
from ulfsynth.enhance import enhance_file, enhance_folder
|
|
47
|
+
verbose = not args.quiet
|
|
48
|
+
if os.path.isdir(args.input):
|
|
49
|
+
enhance_folder(args.input, args.output, device=args.device, verbose=verbose)
|
|
50
|
+
elif os.path.isfile(args.input):
|
|
51
|
+
enhance_file(args.input, args.output, device=args.device, verbose=verbose)
|
|
52
|
+
else:
|
|
53
|
+
raise FileNotFoundError(f"Input not found: {args.input}")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def add_download_parser(subparsers):
|
|
57
|
+
p = subparsers.add_parser("download-weights",
|
|
58
|
+
help="Download pretrained model weights from HuggingFace")
|
|
59
|
+
p.add_argument("--force", action="store_true",
|
|
60
|
+
help="Force re-download even if cached")
|
|
61
|
+
p.set_defaults(func=_run_download)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _run_download(args):
|
|
65
|
+
from ulfsynth.enhance import _download_weights
|
|
66
|
+
_download_weights(force=args.force)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def main():
|
|
70
|
+
parser = argparse.ArgumentParser(
|
|
71
|
+
description="ULF-Synth: Physics-Guided Ultra-Low-Field MRI Enhancement & Simulation"
|
|
72
|
+
)
|
|
73
|
+
subparsers = parser.add_subparsers(title="commands", dest="command")
|
|
74
|
+
subparsers.required = True
|
|
75
|
+
|
|
76
|
+
add_simulate_parser(subparsers)
|
|
77
|
+
add_enhance_parser(subparsers)
|
|
78
|
+
add_download_parser(subparsers)
|
|
79
|
+
|
|
80
|
+
args = parser.parse_args()
|
|
81
|
+
args.func(args)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
if __name__ == "__main__":
|
|
85
|
+
main()
|
ulfsynth/enhance.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Enhance ULF MRI volumes using pretrained restoration models.
|
|
3
|
+
|
|
4
|
+
Downloads weights from HuggingFace on first use, then runs nnU-Net
|
|
5
|
+
translation inference to produce enhanced (HF-like) volumes.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
import urllib.request
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
CACHE_DIR = os.path.join(os.path.expanduser("~"), ".cache", "ulfsynth")
|
|
17
|
+
WEIGHTS_REPO = "https://huggingface.co/toufiqmusah/ulfsynth-weights/resolve/main"
|
|
18
|
+
MODEL_DIR = "Dataset2023/nnUNetTrainerMRCT_kspace__nnResUNetPlans__3d_fullres"
|
|
19
|
+
WEIGHT_FILES = [
|
|
20
|
+
f"{MODEL_DIR}/dataset.json",
|
|
21
|
+
f"{MODEL_DIR}/dataset_fingerprint.json",
|
|
22
|
+
f"{MODEL_DIR}/plans.json",
|
|
23
|
+
f"{MODEL_DIR}/fold_all/checkpoint_best.pth",
|
|
24
|
+
f"{MODEL_DIR}/fold_all/checkpoint_final.pth",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _ensure_nnunet():
|
|
29
|
+
"""Import nnunetv2 or raise a clear error with install instructions."""
|
|
30
|
+
try:
|
|
31
|
+
import nnunetv2 # noqa: F401
|
|
32
|
+
except ImportError:
|
|
33
|
+
# Look for the bundled fork relative to this package
|
|
34
|
+
here = Path(__file__).resolve().parent
|
|
35
|
+
for candidate in [here.parent / "src" / "nn-translation",
|
|
36
|
+
here / "_nnunet",
|
|
37
|
+
Path.cwd() / "src" / "nn-translation"]:
|
|
38
|
+
if (candidate / "setup.py").exists() or (candidate / "pyproject.toml").exists():
|
|
39
|
+
print(f"Installing nnunetv2 fork from {candidate}...")
|
|
40
|
+
subprocess.check_call(
|
|
41
|
+
[sys.executable, "-m", "pip", "install", "-e", str(candidate)],
|
|
42
|
+
)
|
|
43
|
+
import nnunetv2 # noqa: F401
|
|
44
|
+
return
|
|
45
|
+
raise ImportError(
|
|
46
|
+
"nnunetv2 is required for enhancement. Install it with:\n\n"
|
|
47
|
+
f" pip install -e {here.parent / 'src' / 'nn-translation'}\n\n"
|
|
48
|
+
"Or if you installed ulfsynth from PyPI:\n\n"
|
|
49
|
+
" pip install ulfsynth[enhance]\n"
|
|
50
|
+
" pip install nnunetv2\n"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _download_weights(force=False):
|
|
55
|
+
"""Download model weights from HuggingFace to cache directory."""
|
|
56
|
+
weights_dir = os.path.join(CACHE_DIR, "weights")
|
|
57
|
+
os.makedirs(weights_dir, exist_ok=True)
|
|
58
|
+
|
|
59
|
+
missing = []
|
|
60
|
+
for fname in WEIGHT_FILES:
|
|
61
|
+
fpath = os.path.join(weights_dir, fname)
|
|
62
|
+
if not os.path.isfile(fpath) or force:
|
|
63
|
+
missing.append(fname)
|
|
64
|
+
|
|
65
|
+
if missing:
|
|
66
|
+
for fname in missing:
|
|
67
|
+
url = f"{WEIGHTS_REPO}/{fname}"
|
|
68
|
+
fpath = os.path.join(weights_dir, fname)
|
|
69
|
+
os.makedirs(os.path.dirname(fpath), exist_ok=True)
|
|
70
|
+
print(f"Downloading {fname}...")
|
|
71
|
+
urllib.request.urlretrieve(url, fpath)
|
|
72
|
+
print("Weights downloaded successfully.")
|
|
73
|
+
else:
|
|
74
|
+
print("Weights already cached.")
|
|
75
|
+
|
|
76
|
+
return weights_dir
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def enhance_file(input_path, output_path, device="cuda", verbose=True):
|
|
80
|
+
"""Enhance a single ULF NIfTI file.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
input_path: Path to input .nii or .nii.gz file.
|
|
84
|
+
output_path: Path for the enhanced output file.
|
|
85
|
+
device: Device for inference ('cuda', 'cpu', 'mps').
|
|
86
|
+
verbose: Print progress information.
|
|
87
|
+
"""
|
|
88
|
+
input_path = os.path.abspath(input_path)
|
|
89
|
+
output_path = os.path.abspath(output_path)
|
|
90
|
+
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
|
|
91
|
+
|
|
92
|
+
with tempfile.TemporaryDirectory(prefix="ulfsynth_") as tmpdir:
|
|
93
|
+
# nnUNet expects files named {case}_0000.nii.gz
|
|
94
|
+
basename = os.path.basename(input_path)
|
|
95
|
+
if basename.endswith(".nii.gz"):
|
|
96
|
+
stem = basename[:-7]
|
|
97
|
+
else:
|
|
98
|
+
stem = basename[:-4]
|
|
99
|
+
nnunet_input = os.path.join(tmpdir, "input")
|
|
100
|
+
nnunet_output = os.path.join(tmpdir, "output")
|
|
101
|
+
os.makedirs(nnunet_input, exist_ok=True)
|
|
102
|
+
os.makedirs(nnunet_output, exist_ok=True)
|
|
103
|
+
|
|
104
|
+
# Copy input with _0000 suffix
|
|
105
|
+
shutil.copy2(input_path, os.path.join(nnunet_input, f"{stem}_0000.nii.gz"))
|
|
106
|
+
|
|
107
|
+
# Load model
|
|
108
|
+
weights_dir = _download_weights()
|
|
109
|
+
model_folder = os.path.join(weights_dir, MODEL_DIR)
|
|
110
|
+
_ensure_nnunet()
|
|
111
|
+
from nnunetv2.inference.predict_from_raw_data import nnUNetPredictor
|
|
112
|
+
import torch
|
|
113
|
+
|
|
114
|
+
predictor = nnUNetPredictor(
|
|
115
|
+
tile_step_size=0.5,
|
|
116
|
+
use_gaussian=True,
|
|
117
|
+
use_mirroring=True,
|
|
118
|
+
|
|
119
|
+
perform_everything_on_device=True,
|
|
120
|
+
device=torch.device(device),
|
|
121
|
+
verbose=verbose,
|
|
122
|
+
allow_tqdm=verbose,
|
|
123
|
+
verbose_preprocessing=verbose,
|
|
124
|
+
)
|
|
125
|
+
predictor.initialize_from_trained_model_folder(
|
|
126
|
+
model_folder,
|
|
127
|
+
use_folds=["all"],
|
|
128
|
+
checkpoint_name="checkpoint_best.pth",
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# Run inference
|
|
132
|
+
if verbose:
|
|
133
|
+
print(f"Enhancing {basename}...")
|
|
134
|
+
predictor.predict_from_files(
|
|
135
|
+
nnunet_input,
|
|
136
|
+
nnunet_output,
|
|
137
|
+
save_probabilities=False,
|
|
138
|
+
overwrite=True,
|
|
139
|
+
num_processes_preprocessing=1,
|
|
140
|
+
num_processes_segmentation_export=1,
|
|
141
|
+
reconstruction_mode="mean",
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# Find the NIfTI output (filter out JSON summaries added by nnUNet)
|
|
145
|
+
out_candidates = [f for f in os.listdir(nnunet_output)
|
|
146
|
+
if f.endswith('.nii.gz')]
|
|
147
|
+
if not out_candidates:
|
|
148
|
+
raise RuntimeError(
|
|
149
|
+
f"Enhancement produced no NIfTI output. Found: {os.listdir(nnunet_output)}"
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
out_file = os.path.join(nnunet_output, out_candidates[0])
|
|
153
|
+
shutil.move(out_file, output_path)
|
|
154
|
+
|
|
155
|
+
if verbose:
|
|
156
|
+
print(f" -> {output_path}")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def enhance_folder(input_dir, output_dir, device="cuda", verbose=True):
|
|
160
|
+
"""Enhance all NIfTI files in a directory.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
input_dir: Directory containing .nii/.nii.gz files.
|
|
164
|
+
output_dir: Directory for enhanced outputs.
|
|
165
|
+
device: Device for inference.
|
|
166
|
+
verbose: Print progress information.
|
|
167
|
+
"""
|
|
168
|
+
input_dir = os.path.abspath(input_dir)
|
|
169
|
+
output_dir = os.path.abspath(output_dir)
|
|
170
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
171
|
+
|
|
172
|
+
files = sorted(
|
|
173
|
+
f for f in os.listdir(input_dir)
|
|
174
|
+
if f.endswith(".nii") or f.endswith(".nii.gz")
|
|
175
|
+
)
|
|
176
|
+
if not files:
|
|
177
|
+
raise FileNotFoundError(f"No NIfTI files found in {input_dir}")
|
|
178
|
+
|
|
179
|
+
with tempfile.TemporaryDirectory(prefix="ulfsynth_") as tmpdir:
|
|
180
|
+
nnunet_input = os.path.join(tmpdir, "input")
|
|
181
|
+
nnunet_output = os.path.join(tmpdir, "output")
|
|
182
|
+
os.makedirs(nnunet_input, exist_ok=True)
|
|
183
|
+
os.makedirs(nnunet_output, exist_ok=True)
|
|
184
|
+
|
|
185
|
+
# Copy all inputs with _0000 suffix
|
|
186
|
+
name_map = {}
|
|
187
|
+
for fname in files:
|
|
188
|
+
if fname.endswith(".nii.gz"):
|
|
189
|
+
stem = fname[:-7]
|
|
190
|
+
else:
|
|
191
|
+
stem = fname[:-4]
|
|
192
|
+
src = os.path.join(input_dir, fname)
|
|
193
|
+
dst = os.path.join(nnunet_input, f"{stem}_0000.nii.gz")
|
|
194
|
+
shutil.copy2(src, dst)
|
|
195
|
+
name_map[stem] = fname
|
|
196
|
+
|
|
197
|
+
# Load model
|
|
198
|
+
weights_dir = _download_weights()
|
|
199
|
+
model_folder = os.path.join(weights_dir, MODEL_DIR)
|
|
200
|
+
|
|
201
|
+
_ensure_nnunet()
|
|
202
|
+
from nnunetv2.inference.predict_from_raw_data import nnUNetPredictor
|
|
203
|
+
import torch
|
|
204
|
+
|
|
205
|
+
predictor = nnUNetPredictor(
|
|
206
|
+
tile_step_size=0.5,
|
|
207
|
+
use_gaussian=True,
|
|
208
|
+
use_mirroring=True,
|
|
209
|
+
perform_everything_on_device=True,
|
|
210
|
+
device=torch.device(device),
|
|
211
|
+
verbose=verbose,
|
|
212
|
+
allow_tqdm=verbose,
|
|
213
|
+
verbose_preprocessing=verbose,
|
|
214
|
+
)
|
|
215
|
+
predictor.initialize_from_trained_model_folder(
|
|
216
|
+
model_folder,
|
|
217
|
+
use_folds=["all"],
|
|
218
|
+
checkpoint_name="checkpoint_best.pth",
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
if verbose:
|
|
222
|
+
print(f"Enhancing {len(files)} files...")
|
|
223
|
+
predictor.predict_from_files(
|
|
224
|
+
nnunet_input,
|
|
225
|
+
nnunet_output,
|
|
226
|
+
save_probabilities=False,
|
|
227
|
+
overwrite=True,
|
|
228
|
+
num_processes_preprocessing=1,
|
|
229
|
+
num_processes_segmentation_export=1,
|
|
230
|
+
reconstruction_mode="mean",
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
# Rename outputs back (skip JSON summaries)
|
|
234
|
+
for of in os.listdir(nnunet_output):
|
|
235
|
+
if not of.endswith(".nii.gz"):
|
|
236
|
+
continue
|
|
237
|
+
stem = of[:-7].replace("_0000", "")
|
|
238
|
+
original_name = name_map.get(stem, of)
|
|
239
|
+
shutil.move(
|
|
240
|
+
os.path.join(nnunet_output, of),
|
|
241
|
+
os.path.join(output_dir, original_name),
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
if verbose:
|
|
245
|
+
print(f"Done. {len(files)} files written to {output_dir}")
|
ulfsynth/simulate.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Physics-guided ULF MRI simulation from high-field volumes.
|
|
3
|
+
|
|
4
|
+
Simulates signal loss, noise, k-space degradation, and B0
|
|
5
|
+
inhomogeneity effects characteristic of ultra-low-field MRI.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import nibabel as nib
|
|
10
|
+
from scipy.ndimage import gaussian_filter
|
|
11
|
+
from numpy.fft import fftn, ifftn, fftshift, ifftshift
|
|
12
|
+
import os
|
|
13
|
+
|
|
14
|
+
B_HF = 1.5
|
|
15
|
+
B_ULF = 0.064
|
|
16
|
+
POLAR_SCALE = (B_ULF / B_HF) ** 2
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def to_kspace(vol):
|
|
20
|
+
return fftshift(fftn(vol))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def to_ispace(K):
|
|
24
|
+
return np.real(ifftn(ifftshift(K)))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def to_complex_ispace(K):
|
|
28
|
+
return ifftn(ifftshift(K))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def object_mask(vol, thresh=0.05):
|
|
32
|
+
normed = (vol - vol.min()) / (vol.max() - vol.min() + 1e-8)
|
|
33
|
+
return normed > thresh
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def apply_b0_t2star_decay(I_complex, T2, TE, b0_strength, smoothness, k, eps=1e-6):
|
|
37
|
+
H, W, D = I_complex.shape
|
|
38
|
+
B0_map = gaussian_filter(np.random.randn(H, W, D), sigma=smoothness) * b0_strength
|
|
39
|
+
gx, gy, gz = np.gradient(B0_map)
|
|
40
|
+
grad_B0 = np.sqrt(gx**2 + gy**2 + gz**2)
|
|
41
|
+
grad_B0 /= (np.percentile(grad_B0, 95) + eps)
|
|
42
|
+
T2star = 1.0 / ((1.0 / T2) + k * grad_B0 + eps)
|
|
43
|
+
decay = np.exp(-TE / T2star)
|
|
44
|
+
return I_complex * decay
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def noisy_kspace(K, I_ref, signal_target):
|
|
48
|
+
alpha = 0.05
|
|
49
|
+
K_scaled = alpha * K
|
|
50
|
+
mask = object_mask(I_ref)
|
|
51
|
+
I_scaled = np.abs(ifftn(ifftshift(K_scaled)))
|
|
52
|
+
signal_power = np.mean(I_scaled[mask] ** 2)
|
|
53
|
+
sigma = np.sqrt(signal_power / (signal_target + 1e-8))
|
|
54
|
+
noise_img = sigma * np.ones_like(mask, dtype=float) * (
|
|
55
|
+
np.random.randn(*I_scaled.shape) + 1j * np.random.randn(*I_scaled.shape)
|
|
56
|
+
)
|
|
57
|
+
K_noisy = K_scaled + fftshift(fftn(noise_img))
|
|
58
|
+
return K_noisy
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def kspace_crop(K, crop_ratio):
|
|
62
|
+
H, W, D = K.shape
|
|
63
|
+
ch, cw, cd = int(H * crop_ratio), int(W * crop_ratio), int(D * crop_ratio)
|
|
64
|
+
h0, w0, d0 = (H - ch) // 2, (W - cw) // 2, (D - cd) // 2
|
|
65
|
+
K_cropped = np.zeros_like(K, dtype=complex)
|
|
66
|
+
K_cropped[h0:h0+ch, w0:w0+cw, d0:d0+cd] = K[h0:h0+ch, w0:w0+cw, d0:d0+cd]
|
|
67
|
+
return K_cropped
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def undersample_kspace(K, acceleration, center_fraction):
|
|
71
|
+
H, W, D = K.shape
|
|
72
|
+
mask = np.zeros((H, W, D), dtype=bool)
|
|
73
|
+
ch, cw, cd = int(H * center_fraction), int(W * center_fraction), int(D * center_fraction)
|
|
74
|
+
h0, w0, d0 = (H - ch) // 2, (W - cw) // 2, (D - cd) // 2
|
|
75
|
+
mask[h0:h0+ch, w0:w0+cw, d0:d0+cd] = True
|
|
76
|
+
outer = ~mask
|
|
77
|
+
n_sample = int(np.sum(outer) / acceleration)
|
|
78
|
+
coords = np.where(outer)
|
|
79
|
+
idx = np.random.choice(len(coords[0]), n_sample, replace=False)
|
|
80
|
+
mask[coords[0][idx], coords[1][idx], coords[2][idx]] = True
|
|
81
|
+
K_us = K * mask
|
|
82
|
+
I_original = np.abs(ifftn(ifftshift(K)))
|
|
83
|
+
I_us = np.abs(ifftn(ifftshift(K_us))) * object_mask(I_original, thresh=0.05)
|
|
84
|
+
return fftshift(fftn(I_us))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def apply_b0_inhomogeneity(K, distortion_strength, smoothness):
|
|
88
|
+
I_complex = ifftn(ifftshift(K))
|
|
89
|
+
H, W, D = I_complex.shape
|
|
90
|
+
field_map = gaussian_filter(np.random.randn(H, W, D), sigma=smoothness) * distortion_strength
|
|
91
|
+
I_distorted = I_complex * np.exp(2j * np.pi * field_map)
|
|
92
|
+
return fftshift(fftn(I_distorted))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def sample_params(rng=None):
|
|
96
|
+
if rng is None:
|
|
97
|
+
rng = np.random
|
|
98
|
+
return {
|
|
99
|
+
"T2": rng.uniform(0.070, 0.090),
|
|
100
|
+
"TE": rng.uniform(0.100, 0.130),
|
|
101
|
+
"b0_strength": rng.uniform(0.020, 0.040),
|
|
102
|
+
"smoothness": rng.uniform(30, 45),
|
|
103
|
+
"k": rng.uniform(10, 15),
|
|
104
|
+
"signal_target": rng.uniform(15, 50),
|
|
105
|
+
"crop_ratio": rng.uniform(0.45, 0.55),
|
|
106
|
+
"acceleration": int(rng.choice([2, 3], p=[0.7, 0.3])),
|
|
107
|
+
"center_fraction": rng.uniform(0.20, 0.30),
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def simulate_ulf(hf_path, seed=None, params=None):
|
|
112
|
+
if seed is not None:
|
|
113
|
+
np.random.seed(seed)
|
|
114
|
+
if params is None:
|
|
115
|
+
params = sample_params()
|
|
116
|
+
|
|
117
|
+
hf_img = nib.load(hf_path)
|
|
118
|
+
I_hf = hf_img.get_fdata()
|
|
119
|
+
affine = hf_img.affine
|
|
120
|
+
header = hf_img.header.copy()
|
|
121
|
+
|
|
122
|
+
I_scaled = POLAR_SCALE * I_hf
|
|
123
|
+
K_hf = to_kspace(I_scaled)
|
|
124
|
+
I_complex = to_complex_ispace(K_hf)
|
|
125
|
+
|
|
126
|
+
I_b0 = apply_b0_t2star_decay(
|
|
127
|
+
I_complex,
|
|
128
|
+
T2=params["T2"],
|
|
129
|
+
TE=params["TE"],
|
|
130
|
+
b0_strength=params["b0_strength"],
|
|
131
|
+
smoothness=params["smoothness"],
|
|
132
|
+
k=params["k"],
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
K_b0 = to_kspace(I_b0)
|
|
136
|
+
K_noisy = noisy_kspace(K_b0, I_scaled, signal_target=params["signal_target"])
|
|
137
|
+
|
|
138
|
+
K_crop = kspace_crop(K_noisy, crop_ratio=params["crop_ratio"])
|
|
139
|
+
|
|
140
|
+
K_under = undersample_kspace(
|
|
141
|
+
K_crop,
|
|
142
|
+
acceleration=params["acceleration"],
|
|
143
|
+
center_fraction=params["center_fraction"],
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
K_final = apply_b0_inhomogeneity(
|
|
147
|
+
K_under,
|
|
148
|
+
distortion_strength=params["b0_strength"],
|
|
149
|
+
smoothness=max(1, int(params["smoothness"] // 10)),
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
I_ulf = np.abs(to_ispace(K_final)).astype(np.float32)
|
|
153
|
+
return I_ulf, affine, header, params
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def simulate_file(hf_path, out_path, seed=None, verbose=True):
|
|
157
|
+
I_ulf, affine, header, params = simulate_ulf(hf_path, seed=seed)
|
|
158
|
+
os.makedirs(os.path.dirname(os.path.abspath(out_path)) or ".", exist_ok=True)
|
|
159
|
+
nib.save(nib.Nifti1Image(I_ulf, affine, header), out_path)
|
|
160
|
+
if verbose:
|
|
161
|
+
print(f" {os.path.basename(hf_path)} -> {out_path} shape={I_ulf.shape} range=[{I_ulf.min():.3f}, {I_ulf.max():.3f}]")
|
|
162
|
+
return params
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def simulate_folder(in_dir, out_dir, seed=None, verbose=True):
|
|
166
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
167
|
+
files = sorted(f for f in os.listdir(in_dir) if f.endswith(('.nii', '.nii.gz')))
|
|
168
|
+
if not files:
|
|
169
|
+
raise FileNotFoundError(f"No NIfTI files found in {in_dir}")
|
|
170
|
+
results = []
|
|
171
|
+
for fname in files:
|
|
172
|
+
hf_path = os.path.join(in_dir, fname)
|
|
173
|
+
out_path = os.path.join(out_dir, fname)
|
|
174
|
+
file_seed = None if seed is None else seed + abs(hash(fname)) % (2**31)
|
|
175
|
+
params = simulate_file(hf_path, out_path, seed=file_seed, verbose=verbose)
|
|
176
|
+
results.append(params)
|
|
177
|
+
return results
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ulfsynth
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Physics-Guided Ultra-Low-Field MRI Enhancement & Simulation
|
|
5
|
+
Author: Toufiq Musah
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 Toufiq Musah
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Project-URL: homepage, https://github.com/toufiqmusah/ULF-Synth
|
|
29
|
+
Project-URL: repository, https://github.com/toufiqmusah/ULF-Synth
|
|
30
|
+
Project-URL: documentation, https://github.com/toufiqmusah/ULF-Synth#readme
|
|
31
|
+
Project-URL: Bug Tracker, https://github.com/toufiqmusah/ULF-Synth/issues
|
|
32
|
+
Keywords: mri,ultra-low-field,ulf,medical-image-enhancement,image-synthesis,deep-learning,neuroimaging,nnunet,image-translation
|
|
33
|
+
Classifier: Development Status :: 4 - Beta
|
|
34
|
+
Classifier: Intended Audience :: Science/Research
|
|
35
|
+
Classifier: Intended Audience :: Healthcare Industry
|
|
36
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
37
|
+
Classifier: Programming Language :: Python :: 3
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
40
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
41
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
42
|
+
Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
|
|
43
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
44
|
+
Classifier: Topic :: Scientific/Engineering :: Image Processing
|
|
45
|
+
Classifier: Operating System :: OS Independent
|
|
46
|
+
Requires-Python: >=3.10
|
|
47
|
+
Description-Content-Type: text/markdown
|
|
48
|
+
License-File: LICENSE
|
|
49
|
+
Requires-Dist: numpy>=1.21.0
|
|
50
|
+
Requires-Dist: nibabel>=3.2.0
|
|
51
|
+
Requires-Dist: scipy>=1.7.0
|
|
52
|
+
Requires-Dist: torch>=2.1.0
|
|
53
|
+
Provides-Extra: enhance
|
|
54
|
+
Requires-Dist: nnunetv2>=2.5; extra == "enhance"
|
|
55
|
+
Dynamic: license-file
|
|
56
|
+
Dynamic: provides-extra
|
|
57
|
+
Dynamic: requires-dist
|
|
58
|
+
Dynamic: summary
|
|
59
|
+
|
|
60
|
+
<p align="center">
|
|
61
|
+
<img src="assets/method.png" alt="ULF-Synth" width="100%">
|
|
62
|
+
</p>
|
|
63
|
+
|
|
64
|
+
<h1 align="center">ULF-Synth: Physics-Guided Ultra-Low-Field MRI Enhancement for Pediatric Neuroimaging</h1>
|
|
65
|
+
|
|
66
|
+
<p align="center">
|
|
67
|
+
<a href="https://arxiv.org/abs/2605.24625v1"><img src="https://img.shields.io/badge/arXiv-2605.24625-b31b1b.svg" alt="arXiv"></a>
|
|
68
|
+
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT"></a>
|
|
69
|
+
<a href="https://www.python.org/"><img src="https://img.shields.io/badge/python-3.9%2B-blue" alt="Python"></a>
|
|
70
|
+
</p>
|
|
71
|
+
|
|
72
|
+
## Abstract
|
|
73
|
+
|
|
74
|
+
Ultra-low-field (ULF) MRI enables portable and accessible neuroimaging, but suffers from low signal-to-noise ratio and limited spatial resolution relative to high-field (HF) systems. Acquiring paired ULF–HF data for supervised enhancement is often infeasible, particularly in resource-limited settings. We introduce **ULF-Synth**, a framework combining: (i) acquisition-based synthesis of realistic ULF images from HF volumes for large-scale paired training, and (ii) a spatial-frequency domain objective that prioritizes recovery of high-frequency anatomical detail. The formulation is architecture-agnostic, consistently improving structural similarity and perceptual fidelity across encoder-decoder, adversarial, and diffusion-based translation models. Trained exclusively on synthetic data, our models generalize to real 64 mT ULF acquisitions, improving multiclass brain segmentation and achieving higher radiologist preference and diagnostic acceptability in a blinded reader study.
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## Simulation Pipeline
|
|
79
|
+
|
|
80
|
+
The ULF synthesis module models the key physical phenomena distinguishing ULF from HF acquisitions:
|
|
81
|
+
|
|
82
|
+
| | Effect | Implementation |
|
|
83
|
+
|:---:|---|---|
|
|
84
|
+
| 1 | **Signal scaling** | $(B_{{ULF}}/B_{{HF}})^2$ polarization ratio |
|
|
85
|
+
| 2 | **T2\* decay & B0 inhomogeneity** | Spatially-varying exponential decay from random B0 field maps |
|
|
86
|
+
| 3 | **Thermal noise** | Gaussian noise scaled to SNR 15–50 |
|
|
87
|
+
| 4 | **k-space cropping** | Reduced resolution (45–55%) |
|
|
88
|
+
| 5 | **k-space undersampling** | Accelerated acquisition (2×–3×) with center-out sampling |
|
|
89
|
+
| 6 | **B0 off-resonance distortion** | Phase distortion from random B0 field maps |
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## Qualitative Results
|
|
94
|
+
|
|
95
|
+
<p align="center">
|
|
96
|
+
<img src="assets/results.png" alt="Sample results" width="95%">
|
|
97
|
+
</p>
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## Installation
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
git clone https://github.com/toufiqmusah/ULF-Synth.git
|
|
105
|
+
cd ULF-Synth
|
|
106
|
+
pip install -e .
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
This installs `ulfsynth` and all core dependencies (including PyTorch).
|
|
110
|
+
The bundled [nnUNet translation fork](src/nn-translation/) is automatically
|
|
111
|
+
discovered and installed during setup — no extra steps needed.
|
|
112
|
+
|
|
113
|
+
### Install from PyPI (future)
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
pip install ulfsynth # simulation only
|
|
117
|
+
pip install ulfsynth[full] # with enhancement support
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
## CLI
|
|
123
|
+
|
|
124
|
+
The `ulfsynth` package provides three commands:
|
|
125
|
+
|
|
126
|
+
### `ulfsynth simulate` — ULF synthesis from HF volumes
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
# Single volume
|
|
130
|
+
ulfsynth simulate input.nii.gz output.nii.gz
|
|
131
|
+
|
|
132
|
+
# Folder of NIfTI files
|
|
133
|
+
ulfsynth simulate /path/to/hf/scans/ /path/to/ulf/scans/
|
|
134
|
+
|
|
135
|
+
# Reproducible seed
|
|
136
|
+
ulfsynth simulate input.nii.gz output.nii.gz --seed 42
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### `ulfsynth enhance` — ULF→HF restoration (requires nnUNet)
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
# Single volume (CPU)
|
|
143
|
+
ulfsynth enhance --device cpu input.nii.gz output.nii.gz
|
|
144
|
+
|
|
145
|
+
# Folder of NIfTI files (GPU)
|
|
146
|
+
ulfsynth enhance /path/to/ulf/scans/ /path/to/enhanced/scans/
|
|
147
|
+
|
|
148
|
+
# Weights are downloaded from HuggingFace on first use.
|
|
149
|
+
# Pre-download: ulfsynth download-weights
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### `ulfsynth download-weights` — cache pretrained weights
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
ulfsynth download-weights
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Caches model weights from [HuggingFace](https://huggingface.co/toufiqmusah/ulfsynth-weights) to `~/.cache/ulfsynth/`.
|
|
159
|
+
|
|
160
|
+
---
|
|
161
|
+
|
|
162
|
+
## Python API
|
|
163
|
+
|
|
164
|
+
### Simulation
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
from ulfsynth.simulate import simulate_ulf, simulate_file, simulate_folder, sample_params
|
|
168
|
+
|
|
169
|
+
# Generate one ULF volume with random parameters
|
|
170
|
+
ulf_volume, affine, header, params = simulate_ulf("hf_input.nii.gz")
|
|
171
|
+
|
|
172
|
+
# With a fixed seed
|
|
173
|
+
ulf_volume, affine, header, params = simulate_ulf("hf_input.nii.gz", seed=42)
|
|
174
|
+
|
|
175
|
+
# Custom parameters
|
|
176
|
+
params = sample_params()
|
|
177
|
+
params["signal_target"] = 30
|
|
178
|
+
ulf_volume, affine, header, params = simulate_ulf("hf_input.nii.gz", params=params)
|
|
179
|
+
|
|
180
|
+
# Single-file convenience (returns params dict)
|
|
181
|
+
params = simulate_file("hf_input.nii.gz", "ulf_output.nii.gz", seed=42)
|
|
182
|
+
|
|
183
|
+
# Batch folder processing (returns list of params)
|
|
184
|
+
results = simulate_folder("hf_scans/", "ulf_scans/", seed=42)
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Output preserves the input affine and header metadata.
|
|
188
|
+
|
|
189
|
+
### Enhancement
|
|
190
|
+
|
|
191
|
+
```python
|
|
192
|
+
from ulfsynth.enhance import enhance_file, enhance_folder
|
|
193
|
+
|
|
194
|
+
# Single file
|
|
195
|
+
enhance_file("ulf_input.nii.gz", "enhanced_output.nii.gz", device="cpu")
|
|
196
|
+
|
|
197
|
+
# Batch folder processing
|
|
198
|
+
enhance_folder("ulf_scans/", "enhanced_scans/", device="cuda")
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Requires nnUNet (`pip install -e src/nn-translation/`). Weights are auto-downloaded on first call.
|
|
202
|
+
|
|
203
|
+
---
|
|
204
|
+
|
|
205
|
+
## Roadmap
|
|
206
|
+
|
|
207
|
+
- [x] Physics-guided ULF synthesis pipeline
|
|
208
|
+
- [x] Pre-trained enhancement weights — ULF→HF restoration models
|
|
209
|
+
- [x] Python package — `pip install ulfsynth`
|
|
210
|
+
- [ ] Docker image — zero-config containerized pipeline
|
|
211
|
+
|
|
212
|
+
---
|
|
213
|
+
|
|
214
|
+
## Citation
|
|
215
|
+
|
|
216
|
+
```bibtex
|
|
217
|
+
@misc{musah2026ulfsynth,
|
|
218
|
+
title = {ULF-Synth: Physics-Guided Ultra-Low-Field MRI Enhancement for Pediatric Neuroimaging},
|
|
219
|
+
author = {Toufiq Musah and Salvatore Calcagno and Federica Proietto Salanitri and Xiaomeng Li and Maruf Adewole and Marawan Elbatel},
|
|
220
|
+
year = {2026},
|
|
221
|
+
eprint = {2605.24625},
|
|
222
|
+
archivePrefix = {arXiv},
|
|
223
|
+
url = {https://arxiv.org/abs/2605.24625}
|
|
224
|
+
}
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
---
|
|
228
|
+
|
|
229
|
+
## License
|
|
230
|
+
|
|
231
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
ulfsynth/__init__.py,sha256=7TizWjaILVaKCkQLQohnE9eGFreZPBv4sDzX_o6A3pw,339
|
|
2
|
+
ulfsynth/cli.py,sha256=TdJWiCegEJmvkYham5X3SUOpsoiPE2ucrebfUE_4_Eo,3044
|
|
3
|
+
ulfsynth/enhance.py,sha256=jmagU42ZCYwH3EjmQt1lkhGILNKrZupEyaaY8vBIPkw,8553
|
|
4
|
+
ulfsynth/simulate.py,sha256=E883-8CglBY5Spk-9sUHweEWemV4IfpQ02m9yIEg7GQ,5900
|
|
5
|
+
ulfsynth-0.1.0.dist-info/licenses/LICENSE,sha256=CUoI62adLPW53edyqgN_E66KMIAORk5QWw4IoGTKvao,1069
|
|
6
|
+
ulfsynth-0.1.0.dist-info/METADATA,sha256=AYL--4afR33iFA0HUiUumx6nH12DesB6z1ycXSFHaSE,8545
|
|
7
|
+
ulfsynth-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
8
|
+
ulfsynth-0.1.0.dist-info/entry_points.txt,sha256=24J4SOhUWyQMBq6Ru42FPmxK8QgWiaI_kXL2MadP_Qo,47
|
|
9
|
+
ulfsynth-0.1.0.dist-info/top_level.txt,sha256=QWzvK5IuFg-8wm6GZVgLl4gIyLRNkaqxoo3ON45Smr0,9
|
|
10
|
+
ulfsynth-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Toufiq Musah
|
|
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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ulfsynth
|