thigh-us-segmentation 0.1.0__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.
@@ -0,0 +1,123 @@
1
+ Metadata-Version: 2.4
2
+ Name: thigh-us-segmentation
3
+ Version: 0.1.0
4
+ Summary: Pipeline for thigh ultrasound segmentation using nnU-Net and feature extraction
5
+ Author: Mara Concepción Alvarez, Paula Crespo Ortega, Arantxa Villanueva Larre, Rafael Cabeza Laguna
6
+ Requires-Python: <3.12,>=3.10
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: numpy
9
+ Requires-Dist: pandas
10
+ Requires-Dist: SimpleITK
11
+ Requires-Dist: opencv-python
12
+ Requires-Dist: spicy
13
+ Requires-Dist: matplotlib
14
+ Requires-Dist: pyradiomics
15
+ Requires-Dist: requests
16
+ Requires-Dist: nnunetv2==2.6.4
17
+
18
+ # ThighUSSegmentation
19
+
20
+ Python library for automatic thigh ultrasound segmentation and analysis using nnU-Net.
21
+
22
+ ## Features
23
+
24
+ - Automatic image conversion to `.mha`
25
+ - Preprocessing (active region detection)
26
+ - nnU-Net inference
27
+ - Muscle thickness computation
28
+ - Radiomics feature extraction
29
+ - Export of anatomical landmarks (`.mrk.json`)
30
+ - Automatic model download from Zenodo
31
+
32
+ ## Installation
33
+
34
+ ```bash
35
+ pip install thigh-us-segmentation
36
+ ```
37
+
38
+ ## Usage
39
+
40
+ ```python
41
+ from ThighUSSegmentation import run_full_pipeline
42
+
43
+ result = run_full_pipeline(
44
+ input_image_path="image.dcm",
45
+ output_root="outputs",
46
+ case_id = "case001"
47
+ )
48
+
49
+ print(result["df_results"])
50
+
51
+ # OR
52
+ result = run_full_pipeline(
53
+ input_image_path="image.mha",
54
+ output_root="outputs",
55
+ models_root="D:/mis_modelos", #You can also specify your own model path.
56
+ )
57
+ ```
58
+
59
+ ## Output
60
+
61
+ outputs/
62
+ └── case001/
63
+ ├── image_converted.mha
64
+ ├── image_preprocessed.mha
65
+ ├── case001_labelmap.mha
66
+ ├── markups/
67
+ └── inference_log.txt
68
+
69
+ ## Returned Results
70
+
71
+ The pipeline returns a dictionary:
72
+
73
+ {
74
+ "case_id": str,
75
+ "segmentation_path": str,
76
+ "mrk_paths": dict,
77
+ "df_distances": pd.DataFrame,
78
+ "df_textures": pd.DataFrame,
79
+ "df_results": pd.DataFrame
80
+ }
81
+
82
+ ## Model
83
+
84
+ The pretrained nnU-Net model is automatically downloaded from Zenodo on first use.
85
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.19914473.svg)](https://doi.org/10.5281/zenodo.19914473)
86
+
87
+ Expected strucutre:
88
+
89
+ Dataset001_ThighUS/
90
+ └── nnUNetTrainer__nnUNetPlans__2d/
91
+ ├── dataset.json
92
+ ├── plans.json
93
+ ├── fold_0/
94
+ ├── fold_1/
95
+ ├── fold_2/
96
+ ├── fold_3/
97
+ └── fold_4/
98
+
99
+
100
+ ## Requirements
101
+
102
+ · Python ≥ 3.9
103
+ · SimpleITK
104
+ · PyRadiomics
105
+ · OpenCV
106
+ · NumPy / SciPy / Pandas
107
+
108
+ ## Citation
109
+
110
+ If you use this work, please cite:
111
+
112
+ Isensee et al., nnU-Net: a self-configuring method for deep learning-based biomedical image segmentation. Nature Methods (2021)
113
+
114
+ Mara Concepción Alavarez. (2026).
115
+ Thigh Ultrasound Segmentation Model (nnU-Net).
116
+ Zenodo. https://doi.org/10.5281/zenodo.19914473
117
+
118
+ ## License
119
+
120
+ This project uses the following licenses:
121
+ · Code: MIT License (or the one you choose)
122
+ · Model weights (Zenodo): CC-BY 4.0
123
+
@@ -0,0 +1,106 @@
1
+ # ThighUSSegmentation
2
+
3
+ Python library for automatic thigh ultrasound segmentation and analysis using nnU-Net.
4
+
5
+ ## Features
6
+
7
+ - Automatic image conversion to `.mha`
8
+ - Preprocessing (active region detection)
9
+ - nnU-Net inference
10
+ - Muscle thickness computation
11
+ - Radiomics feature extraction
12
+ - Export of anatomical landmarks (`.mrk.json`)
13
+ - Automatic model download from Zenodo
14
+
15
+ ## Installation
16
+
17
+ ```bash
18
+ pip install thigh-us-segmentation
19
+ ```
20
+
21
+ ## Usage
22
+
23
+ ```python
24
+ from ThighUSSegmentation import run_full_pipeline
25
+
26
+ result = run_full_pipeline(
27
+ input_image_path="image.dcm",
28
+ output_root="outputs",
29
+ case_id = "case001"
30
+ )
31
+
32
+ print(result["df_results"])
33
+
34
+ # OR
35
+ result = run_full_pipeline(
36
+ input_image_path="image.mha",
37
+ output_root="outputs",
38
+ models_root="D:/mis_modelos", #You can also specify your own model path.
39
+ )
40
+ ```
41
+
42
+ ## Output
43
+
44
+ outputs/
45
+ └── case001/
46
+ ├── image_converted.mha
47
+ ├── image_preprocessed.mha
48
+ ├── case001_labelmap.mha
49
+ ├── markups/
50
+ └── inference_log.txt
51
+
52
+ ## Returned Results
53
+
54
+ The pipeline returns a dictionary:
55
+
56
+ {
57
+ "case_id": str,
58
+ "segmentation_path": str,
59
+ "mrk_paths": dict,
60
+ "df_distances": pd.DataFrame,
61
+ "df_textures": pd.DataFrame,
62
+ "df_results": pd.DataFrame
63
+ }
64
+
65
+ ## Model
66
+
67
+ The pretrained nnU-Net model is automatically downloaded from Zenodo on first use.
68
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.19914473.svg)](https://doi.org/10.5281/zenodo.19914473)
69
+
70
+ Expected strucutre:
71
+
72
+ Dataset001_ThighUS/
73
+ └── nnUNetTrainer__nnUNetPlans__2d/
74
+ ├── dataset.json
75
+ ├── plans.json
76
+ ├── fold_0/
77
+ ├── fold_1/
78
+ ├── fold_2/
79
+ ├── fold_3/
80
+ └── fold_4/
81
+
82
+
83
+ ## Requirements
84
+
85
+ · Python ≥ 3.9
86
+ · SimpleITK
87
+ · PyRadiomics
88
+ · OpenCV
89
+ · NumPy / SciPy / Pandas
90
+
91
+ ## Citation
92
+
93
+ If you use this work, please cite:
94
+
95
+ Isensee et al., nnU-Net: a self-configuring method for deep learning-based biomedical image segmentation. Nature Methods (2021)
96
+
97
+ Mara Concepción Alavarez. (2026).
98
+ Thigh Ultrasound Segmentation Model (nnU-Net).
99
+ Zenodo. https://doi.org/10.5281/zenodo.19914473
100
+
101
+ ## License
102
+
103
+ This project uses the following licenses:
104
+ · Code: MIT License (or the one you choose)
105
+ · Model weights (Zenodo): CC-BY 4.0
106
+
@@ -0,0 +1,11 @@
1
+ from .pipeline import run_full_pipeline
2
+ from .textures import extract_textures_dataframe
3
+ from .distances import extract_distances_dataframe
4
+ from .preprocessing import preprocess_active_region
5
+
6
+ __all__ = [
7
+ "run_full_pipeline",
8
+ "extract_textures_dataframe",
9
+ "extract_distances_dataframe",
10
+ "preprocess_active_region",
11
+ ]
@@ -0,0 +1,299 @@
1
+ from pathlib import Path
2
+ import json
3
+
4
+ import numpy as np
5
+ import pandas as pd
6
+ import SimpleITK as sitk
7
+ import scipy.ndimage as ndi
8
+
9
+
10
+ def largest_component(mask):
11
+ labeled, num = ndi.label(mask)
12
+ if num == 0:
13
+ return mask
14
+
15
+ sizes = ndi.sum(mask, labeled, range(1, num + 1))
16
+ largest = np.argmax(sizes) + 1
17
+ return labeled == largest
18
+
19
+
20
+ def clean_rf_mask(mask_rf):
21
+ rf = largest_component(mask_rf)
22
+ rf = ndi.binary_erosion(rf, iterations=2)
23
+
24
+ dist = ndi.distance_transform_edt(rf)
25
+ rf_valid = dist > 2
26
+
27
+ return rf, rf_valid
28
+
29
+
30
+ def compute_thickness(masks, img2d):
31
+ rf = masks["rf_clean"]
32
+ femur = masks["femur"]
33
+ epi = masks["epi"]
34
+
35
+ H, W = rf.shape
36
+
37
+ rf_vals = []
38
+ rf_max_vals = []
39
+ muscle_vals = []
40
+ sat_vals = []
41
+ vi_vals = []
42
+ lines = []
43
+
44
+ coords = np.column_stack(np.where(femur))
45
+
46
+ if len(coords) == 0:
47
+ return {"lines": []}
48
+
49
+ x_center = int(np.mean(coords[:, 1]))
50
+
51
+ y_min = np.min(coords[:, 0])
52
+ top_band = coords[coords[:, 0] < y_min + 3]
53
+
54
+ dist = np.abs(top_band[:, 1] - x_center)
55
+ idx = np.argmin(dist)
56
+
57
+ _, x_femur_c = top_band[idx]
58
+
59
+ window = 10
60
+ xmin = max(0, x_femur_c - window)
61
+ xmax = min(W, x_femur_c + window)
62
+
63
+ for x in range(xmin, xmax):
64
+ col_rf = rf[:, x]
65
+ col_femur = femur[:, x]
66
+ col_epi = epi[:, x]
67
+
68
+ if np.sum(col_rf) == 0:
69
+ continue
70
+
71
+ col_img = img2d[:, x]
72
+ y_rf = np.where(col_rf)[0]
73
+
74
+ if len(y_rf) < 5:
75
+ continue
76
+
77
+ y_rf_top = y_rf.min()
78
+ y_rf_bot = y_rf.max()
79
+
80
+ rf_max_vals.append(y_rf_bot - y_rf_top)
81
+
82
+ top_start = max(0, y_rf_top - 20)
83
+ top_end = y_rf_top
84
+ search_top = col_img[top_start:top_end]
85
+ y_top = np.argmax(search_top) + top_start if len(search_top) > 0 else y_rf_top
86
+
87
+ bot_start = y_rf_bot
88
+ bot_end = min(H, y_rf_bot + 20)
89
+ search_bot = col_img[bot_start:bot_end]
90
+ y_bot = np.argmax(search_bot) + bot_start if len(search_bot) > 0 else y_rf_bot
91
+
92
+ y_femur = np.where(col_femur)[0].min() if np.any(col_femur) else None
93
+ y_epi = np.where(col_epi)[0].max() if np.any(col_epi) else None
94
+
95
+ rf_vals.append(y_bot - y_top)
96
+
97
+ if y_femur is not None:
98
+ vi_vals.append(y_femur - y_bot)
99
+ muscle_vals.append(y_femur - y_top)
100
+
101
+ if y_epi is not None:
102
+ sat_vals.append(y_top - y_epi)
103
+
104
+ lines.append({
105
+ "x": int(x),
106
+ "y_top": int(y_top),
107
+ "y_bot": int(y_bot),
108
+ "y_femur": None if y_femur is None else int(y_femur),
109
+ "y_epi": None if y_epi is None else int(y_epi),
110
+ })
111
+
112
+ return {
113
+ "rf_mean": np.mean(rf_vals) if rf_vals else None,
114
+ "rf_median": np.median(rf_vals) if rf_vals else None,
115
+ "rf_alt": np.max(rf_max_vals) if rf_max_vals else None,
116
+ "vi_mean": np.mean(vi_vals) if vi_vals else None,
117
+ "muscle_mean": np.mean(muscle_vals) if muscle_vals else None,
118
+ "sat_mean": np.mean(sat_vals) if sat_vals else None,
119
+ "lines": lines,
120
+ }
121
+
122
+
123
+ def make_point(label, x, y, img_sitk, description=""):
124
+ position = img_sitk.TransformIndexToPhysicalPoint((int(x), int(y), 0))
125
+
126
+ return {
127
+ "label": label,
128
+ "description": description,
129
+ "position": [
130
+ float(position[0]),
131
+ float(position[1]),
132
+ float(position[2]),
133
+ ],
134
+ "orientation": [
135
+ 1.0, 0.0, 0.0,
136
+ 0.0, 1.0, 0.0,
137
+ 0.0, 0.0, 1.0,
138
+ ],
139
+ "selected": True,
140
+ "locked": False,
141
+ "visibility": True,
142
+ "positionStatus": "defined",
143
+ }
144
+
145
+
146
+ def save_single_mrk(output_path, markup_type, control_points, color):
147
+ output_path = Path(output_path)
148
+ output_path.parent.mkdir(parents=True, exist_ok=True)
149
+
150
+ if output_path.exists():
151
+ output_path.unlink()
152
+
153
+ mrk = {
154
+ "@schema": "https://raw.githubusercontent.com/Slicer/Slicer/main/Modules/Loadable/Markups/Resources/Schema/markups-schema-v1.0.3.json#",
155
+ "markups": [
156
+ {
157
+ "type": markup_type,
158
+ "coordinateSystem": "LPS",
159
+ "locked": False,
160
+ "labelFormat": "%N-%d",
161
+ "controlPoints": control_points,
162
+ "measurements": [],
163
+ "display": {
164
+ "visibility": True,
165
+ "color": color,
166
+ "selectedColor": color,
167
+ "glyphType": "Sphere3D",
168
+ "glyphScale": 5.0,
169
+ "textScale": 4.5,
170
+ },
171
+ }
172
+ ],
173
+ }
174
+
175
+ with open(output_path, "w", encoding="utf-8") as f:
176
+ json.dump(mrk, f, indent=4)
177
+
178
+ return output_path
179
+
180
+
181
+ def save_points_mrk_json(thickness, output_dir, case_id, img_sitk):
182
+ output_dir = Path(output_dir)
183
+ output_dir.mkdir(parents=True, exist_ok=True)
184
+
185
+ lines = thickness.get("lines", [])
186
+
187
+ if len(lines) == 0:
188
+ raise ValueError("No hay líneas de medición para guardar puntos.")
189
+
190
+ line_center = lines[len(lines) // 2]
191
+
192
+ x_c = line_center["x"]
193
+ y_top_c = line_center["y_top"]
194
+ y_bot_c = line_center["y_bot"]
195
+ y_femur_c = line_center["y_femur"]
196
+ y_epi_c = line_center["y_epi"]
197
+
198
+ saved_paths = {}
199
+
200
+ points = {
201
+ "epidermis": ("Epidermis", y_epi_c, [1.0, 0.0, 0.0]),
202
+ "fascia_lata": ("Fascia Lata", y_top_c, [0.0, 1.0, 0.0]),
203
+ "aponeurosis": ("Aponeurosis", y_bot_c, [0.0, 0.0, 1.0]),
204
+ "femur": ("Femur", y_femur_c, [1.0, 1.0, 0.0]),
205
+ }
206
+
207
+ for key, (label, y, color) in points.items():
208
+ if y is None:
209
+ continue
210
+
211
+ path = output_dir / f"{case_id}_{key}.mrk.json"
212
+
213
+ saved_paths[key] = save_single_mrk(
214
+ output_path=path,
215
+ markup_type="Fiducial",
216
+ control_points=[
217
+ make_point(label, x_c, y, img_sitk)
218
+ ],
219
+ color=color,
220
+ )
221
+
222
+ if y_epi_c is not None and y_femur_c is not None:
223
+ path = output_dir / f"{case_id}_central_line.mrk.json"
224
+
225
+ saved_paths["central_line"] = save_single_mrk(
226
+ output_path=path,
227
+ markup_type="Line",
228
+ control_points=[
229
+ make_point("Central_start_epi", x_c, y_epi_c, img_sitk, "Central blanca"),
230
+ make_point("Central_end_femur", x_c, y_femur_c, img_sitk, "Central blanca"),
231
+ ],
232
+ color=[1.0, 1.0, 1.0],
233
+ )
234
+
235
+ return saved_paths
236
+
237
+
238
+ def extract_distances_dataframe(
239
+ image_path: str,
240
+ mask_path: str,
241
+ output_mrk_json_path: str,
242
+ case_id: str | None = None,
243
+ epi_label: int = 1,
244
+ femur_label: int = 2,
245
+ rf_label: int = 3,
246
+ ):
247
+ image_path = Path(image_path)
248
+ mask_path = Path(mask_path)
249
+
250
+ if case_id is None:
251
+ case_id = mask_path.name.replace(".nii.gz", "").replace(".mha", "")
252
+
253
+ img_sitk = sitk.ReadImage(str(image_path))
254
+ lab_sitk = sitk.ReadImage(str(mask_path))
255
+
256
+ img = sitk.GetArrayFromImage(img_sitk)
257
+ lab = sitk.GetArrayFromImage(lab_sitk)
258
+
259
+ img2d = img[0]
260
+ lab2d = lab[0]
261
+
262
+ masks = {
263
+ "epi": lab2d == epi_label,
264
+ "femur": lab2d == femur_label,
265
+ "rf": lab2d == rf_label,
266
+ }
267
+
268
+ rf_clean, rf_valid = clean_rf_mask(masks["rf"])
269
+ masks["rf_clean"] = rf_clean
270
+ masks["rf_valid"] = rf_valid
271
+
272
+ thickness = compute_thickness(masks, img2d)
273
+
274
+ spacing = img_sitk.GetSpacing()
275
+ pixel_size_y = spacing[1]
276
+
277
+ def to_mm(value):
278
+ return None if value is None else float(value * pixel_size_y)
279
+
280
+ row = {
281
+ "case_id": case_id,
282
+ "rf_mm": to_mm(thickness.get("rf_mean")),
283
+ "rf_median_mm": to_mm(thickness.get("rf_median")),
284
+ "rf_alt_mm": to_mm(thickness.get("rf_alt")),
285
+ "vi_mm": to_mm(thickness.get("vi_mean")),
286
+ "sat_mm": to_mm(thickness.get("sat_mean")),
287
+ "muscle_mm": to_mm(thickness.get("muscle_mean")),
288
+ }
289
+
290
+ df = pd.DataFrame([row])
291
+
292
+ mrk_paths = save_points_mrk_json(
293
+ thickness=thickness,
294
+ output_dir=output_mrk_json_path,
295
+ case_id=case_id,
296
+ img_sitk=img_sitk,
297
+ )
298
+
299
+ return df, mrk_paths
@@ -0,0 +1,81 @@
1
+ from pathlib import Path
2
+ import shutil
3
+ import logging
4
+
5
+ import SimpleITK as sitk
6
+
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ def convert_to_mha(
12
+ input_path: str,
13
+ output_dir: str,
14
+ output_filename: str = "image_converted.mha",
15
+ ) -> str:
16
+ """
17
+ Convierte una imagen de entrada a .mha y la guarda dentro de output_dir.
18
+
19
+ Soporta:
20
+ - .mha
21
+ - .nii
22
+ - .nii.gz
23
+ - .dcm
24
+ - carpeta DICOM
25
+
26
+ La salida será:
27
+ output_dir / image_converted.mha
28
+ """
29
+
30
+ input_path = Path(input_path)
31
+ output_dir = Path(output_dir)
32
+ output_dir.mkdir(parents=True, exist_ok=True)
33
+
34
+ if not input_path.exists():
35
+ raise FileNotFoundError(f"No existe la entrada: {input_path}")
36
+
37
+ if not output_filename.endswith(".mha"):
38
+ raise ValueError("output_filename debe terminar en .mha")
39
+
40
+ output_path = output_dir / output_filename
41
+
42
+ if output_path.exists():
43
+ output_path.unlink()
44
+
45
+ if input_path.is_file() and input_path.suffix.lower() == ".mha":
46
+ shutil.copy2(input_path, output_path)
47
+ return str(output_path)
48
+
49
+ if input_path.is_file():
50
+ logger.info(f"Convirtiendo archivo a .mha: {input_path}")
51
+ img = sitk.ReadImage(str(input_path))
52
+ sitk.WriteImage(img, str(output_path))
53
+ return str(output_path)
54
+
55
+ if input_path.is_dir():
56
+ logger.info(f"Leyendo carpeta DICOM: {input_path}")
57
+
58
+ reader = sitk.ImageSeriesReader()
59
+ series_ids = reader.GetGDCMSeriesIDs(str(input_path))
60
+
61
+ if not series_ids:
62
+ raise ValueError(f"No se encontró ninguna serie DICOM en: {input_path}")
63
+
64
+ if len(series_ids) > 1:
65
+ logger.warning(
66
+ f"Se encontraron {len(series_ids)} series DICOM. "
67
+ f"Se usará la primera: {series_ids[0]}"
68
+ )
69
+
70
+ series_files = reader.GetGDCMSeriesFileNames(
71
+ str(input_path),
72
+ series_ids[0],
73
+ )
74
+
75
+ reader.SetFileNames(series_files)
76
+ img = reader.Execute()
77
+ sitk.WriteImage(img, str(output_path))
78
+
79
+ return str(output_path)
80
+
81
+ raise ValueError(f"Formato no soportado: {input_path}")