Skip to content

Commit c783348

Browse files
committed
generalize radiomic exctraction to all mask_organ and mask_organ_tumor found in df
1 parent 5c89408 commit c783348

2 files changed

Lines changed: 168 additions & 116 deletions

File tree

src/imperandi/extract/radiomics.py

Lines changed: 155 additions & 114 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,46 @@ def mask_has_voxels(mask, sitk_module) -> bool:
217217
return bool(sitk_module.GetArrayViewFromImage(mask).sum() > 0)
218218

219219

220+
def _extract_original_features(
221+
result: Dict[str, Any],
222+
*,
223+
prefix: str,
224+
include_shape: bool = True,
225+
include_non_shape: bool = True,
226+
) -> Dict[str, Any]:
227+
features: Dict[str, Any] = {}
228+
for key, value in result.items():
229+
skey = str(key)
230+
if not skey.startswith("original"):
231+
continue
232+
is_shape = skey.startswith("original_shape_")
233+
if (is_shape and not include_shape) or ((not is_shape) and not include_non_shape):
234+
continue
235+
features[f"{prefix}_{skey}"] = value
236+
return features
237+
238+
239+
def _resample_to_reference_if_needed(mask_image, reference_image, sitk_module):
240+
if (
241+
mask_image.GetSize(),
242+
mask_image.GetSpacing(),
243+
mask_image.GetOrigin(),
244+
mask_image.GetDirection(),
245+
) == (
246+
reference_image.GetSize(),
247+
reference_image.GetSpacing(),
248+
reference_image.GetOrigin(),
249+
reference_image.GetDirection(),
250+
):
251+
return mask_image
252+
253+
rs = sitk_module.ResampleImageFilter()
254+
rs.SetReferenceImage(reference_image)
255+
rs.SetInterpolator(sitk_module.sitkNearestNeighbor)
256+
rs.SetDefaultPixelValue(0)
257+
return rs.Execute(mask_image)
258+
259+
220260
def extract_radiomics_safe(
221261
image_path: str,
222262
mask_path: Optional[str],
@@ -235,17 +275,13 @@ def extract_radiomics_safe(
235275

236276
image = sitk_module.ReadImage(image_path)
237277
result = extractor.execute(image, mask_image)
238-
features = {
239-
f"{prefix}_{k}": v
240-
for k, v in result.items()
241-
if str(k).startswith("original")
242-
}
278+
features = _extract_original_features(result, prefix=prefix)
243279
return features, None
244280
except Exception as exc:
245281
return {}, f"Error extracting {prefix} features: {exc}"
246282

247283

248-
def extract_radiomics_liver_minus_tumor(
284+
def extract_radiomics_organ_minus_tumor(
249285
image_path: str,
250286
liver_mask_path: Optional[str],
251287
tumor_mask_path: Optional[str],
@@ -255,59 +291,109 @@ def extract_radiomics_liver_minus_tumor(
255291
prefix: str = "liver",
256292
) -> Tuple[Dict[str, Any], Optional[str]]:
257293
if not liver_mask_path or not Path(liver_mask_path).exists():
258-
return {}, "missing liver mask"
294+
return {}, f"missing {prefix} mask"
259295

260296
try:
261297
img = sitk_module.ReadImage(image_path)
262-
liver = sitk_module.ReadImage(liver_mask_path)
298+
organ = sitk_module.ReadImage(liver_mask_path)
263299

264-
if sitk_module.GetArrayViewFromImage(liver).sum() == 0:
265-
return {}, "empty liver mask"
300+
if sitk_module.GetArrayViewFromImage(organ).sum() == 0:
301+
return {}, f"empty {prefix} mask"
266302

267-
liver_bin = sitk_module.Cast(
268-
sitk_module.NotEqual(liver, 0), sitk_module.sitkUInt8
303+
organ_bin = sitk_module.Cast(
304+
sitk_module.NotEqual(organ, 0), sitk_module.sitkUInt8
305+
)
306+
has_tumor = bool(tumor_mask_path and Path(tumor_mask_path).exists())
307+
308+
if not has_tumor:
309+
result = extractor.execute(img, organ_bin)
310+
return _extract_original_features(result, prefix=prefix), None
311+
312+
tumor = sitk_module.ReadImage(tumor_mask_path)
313+
if sitk_module.GetArrayViewFromImage(tumor).sum() == 0:
314+
result = extractor.execute(img, organ_bin)
315+
return _extract_original_features(result, prefix=prefix), None
316+
317+
tumor = _resample_to_reference_if_needed(tumor, organ, sitk_module)
318+
tumor_bin = sitk_module.Cast(sitk_module.NotEqual(tumor, 0), sitk_module.sitkUInt8)
319+
organ_minus_tumor = sitk_module.And(
320+
organ_bin,
321+
sitk_module.Cast(sitk_module.Not(tumor_bin), sitk_module.sitkUInt8),
269322
)
270323

271-
if tumor_mask_path and Path(tumor_mask_path).exists():
272-
tumor = sitk_module.ReadImage(tumor_mask_path)
273-
if sitk_module.GetArrayViewFromImage(tumor).sum() > 0:
274-
if (
275-
tumor.GetSize(),
276-
tumor.GetSpacing(),
277-
tumor.GetOrigin(),
278-
tumor.GetDirection(),
279-
) != (
280-
liver.GetSize(),
281-
liver.GetSpacing(),
282-
liver.GetOrigin(),
283-
liver.GetDirection(),
284-
):
285-
rs = sitk_module.ResampleImageFilter()
286-
rs.SetReferenceImage(liver)
287-
rs.SetInterpolator(sitk_module.sitkNearestNeighbor)
288-
rs.SetDefaultPixelValue(0)
289-
tumor = rs.Execute(tumor)
290-
291-
tumor_bin = sitk_module.Cast(
292-
sitk_module.NotEqual(tumor, 0), sitk_module.sitkUInt8
293-
)
294-
liver_bin = sitk_module.And(
295-
liver_bin,
296-
sitk_module.Cast(sitk_module.Not(tumor_bin), sitk_module.sitkUInt8),
297-
)
298-
299-
if sitk_module.GetArrayViewFromImage(liver_bin).sum() == 0:
300-
return {}, "liver_minus_tumor mask is empty"
301-
302-
result = extractor.execute(img, liver_bin)
303-
features = {
304-
f"{prefix}_{k}": v
305-
for k, v in result.items()
306-
if str(k).startswith("original")
307-
}
324+
shape_result = extractor.execute(img, organ_bin)
325+
features = _extract_original_features(
326+
shape_result,
327+
prefix=prefix,
328+
include_shape=True,
329+
include_non_shape=False,
330+
)
331+
332+
if sitk_module.GetArrayViewFromImage(organ_minus_tumor).sum() == 0:
333+
return features, f"{prefix}_minus_tumor mask is empty"
334+
335+
non_shape_result = extractor.execute(img, organ_minus_tumor)
336+
features.update(
337+
_extract_original_features(
338+
non_shape_result,
339+
prefix=prefix,
340+
include_shape=False,
341+
include_non_shape=True,
342+
)
343+
)
308344
return features, None
309345
except Exception as exc:
310-
return {}, f"Error extracting liver_minus_tumor: {exc}"
346+
return {}, f"Error extracting {prefix}_minus_tumor: {exc}"
347+
348+
349+
def _get_mask_columns(df: pd.DataFrame) -> list[str]:
350+
return [col for col in df.columns if col.startswith("mask_")]
351+
352+
353+
def _extract_row_features(
354+
row: pd.Series,
355+
mask_columns: list[str],
356+
*,
357+
extractor,
358+
sitk_module,
359+
) -> Tuple[Dict[str, Any], list[str]]:
360+
image_path = row.get("nifti_path")
361+
features: Dict[str, Any] = {}
362+
messages: list[str] = []
363+
364+
if not isinstance(image_path, str) or not Path(image_path).exists():
365+
return {}, [f"CT image path is missing or invalid: {image_path}"]
366+
367+
mask_columns_set = set(mask_columns)
368+
for mask_col in mask_columns:
369+
prefix = mask_col.replace("mask_", "", 1)
370+
mask_path = row.get(mask_col)
371+
372+
if prefix.endswith("_tumor"):
373+
roi_features, roi_msg = extract_radiomics_safe(
374+
image_path,
375+
mask_path,
376+
prefix,
377+
extractor=extractor,
378+
sitk_module=sitk_module,
379+
)
380+
else:
381+
tumor_col = f"{mask_col}_tumor"
382+
tumor_path = row.get(tumor_col) if tumor_col in mask_columns_set else None
383+
roi_features, roi_msg = extract_radiomics_organ_minus_tumor(
384+
image_path,
385+
mask_path,
386+
tumor_path,
387+
extractor=extractor,
388+
sitk_module=sitk_module,
389+
prefix=prefix,
390+
)
391+
392+
features.update(roi_features)
393+
if roi_msg:
394+
messages.append(roi_msg)
395+
396+
return features, messages
311397

312398

313399
def extract_radiomics_from_dataframe(
@@ -319,51 +405,27 @@ def extract_radiomics_from_dataframe(
319405
) -> Tuple[pd.DataFrame, pd.DataFrame]:
320406
all_features = []
321407
errors = []
408+
mask_columns = _get_mask_columns(df)
322409

323410
iterator = df.iterrows()
324411
if verbose:
325412
iterator = tqdm(iterator, total=len(df), desc="Radiomics")
326413

327414
for idx, row in iterator:
328-
image_path = row.get("nifti_path")
329-
liver_mask_path = row.get("mask_liver") # row.get("liver_path")
330-
tumor_mask_path = row.get("mask_liver_tumor") # row.get("liver_tumor_path")
415+
features, messages = _extract_row_features(
416+
row,
417+
mask_columns,
418+
extractor=extractor,
419+
sitk_module=sitk_module,
420+
)
331421

332-
if not isinstance(image_path, str) or not Path(image_path).exists():
422+
if messages and "CT image path is missing or invalid" in messages[0]:
333423
error_row = row.to_dict()
334-
error_row["error_message"] = (
335-
f"CT image path is missing or invalid: {image_path}"
336-
)
424+
error_row["error_message"] = messages[0]
337425
errors.append(error_row)
338426
all_features.append({})
339427
continue
340428

341-
features = {}
342-
messages = []
343-
344-
liver_features, liver_msg = extract_radiomics_liver_minus_tumor(
345-
image_path,
346-
liver_mask_path,
347-
tumor_mask_path,
348-
extractor=extractor,
349-
sitk_module=sitk_module,
350-
prefix="liver",
351-
)
352-
features.update(liver_features)
353-
if liver_msg:
354-
messages.append(liver_msg)
355-
356-
tumor_features, tumor_msg = extract_radiomics_safe(
357-
image_path,
358-
tumor_mask_path,
359-
"tumor",
360-
extractor=extractor,
361-
sitk_module=sitk_module,
362-
)
363-
features.update(tumor_features)
364-
if tumor_msg:
365-
messages.append(tumor_msg)
366-
367429
all_features.append(features)
368430
if not features:
369431
error_row = row.to_dict()
@@ -422,6 +484,7 @@ def main(args: argparse.Namespace) -> None:
422484
raise KeyError("column 'nifti_path' missing")
423485
if not args.skip_filter:
424486
df = filter_df(df)
487+
mask_columns = _get_mask_columns(df)
425488

426489
logger.info("Extracting radiomics from %d rows", len(df))
427490
completed_indices: set[int] = set()
@@ -461,45 +524,23 @@ def _checkpoint_write(*, force: bool = False) -> None:
461524
if src_idx in completed_indices:
462525
continue
463526
row = df.loc[idx]
464-
image_path = row.get("nifti_path")
465-
liver_mask_path = row.get("mask_liver")
466-
tumor_mask_path = row.get("mask_liver_tumor")
467527

468-
if not isinstance(image_path, str) or not Path(image_path).exists():
528+
features, messages = _extract_row_features(
529+
row,
530+
mask_columns,
531+
extractor=extractor,
532+
sitk_module=sitk_module,
533+
)
534+
535+
if messages and "CT image path is missing or invalid" in messages[0]:
469536
error_row = row.to_dict()
470-
error_row["error_message"] = (
471-
f"CT image path is missing or invalid: {image_path}"
472-
)
537+
error_row["error_message"] = messages[0]
473538
errors_by_idx[src_idx] = error_row
474539
completed_indices.add(src_idx)
475540
ckpt.mark_processed()
476541
_checkpoint_write(force=False)
477542
continue
478543

479-
features = {}
480-
messages = []
481-
liver_features, liver_msg = extract_radiomics_liver_minus_tumor(
482-
image_path,
483-
liver_mask_path,
484-
tumor_mask_path,
485-
extractor=extractor,
486-
sitk_module=sitk_module,
487-
prefix="liver",
488-
)
489-
features.update(liver_features)
490-
if liver_msg:
491-
messages.append(liver_msg)
492-
tumor_features, tumor_msg = extract_radiomics_safe(
493-
image_path,
494-
tumor_mask_path,
495-
"tumor",
496-
extractor=extractor,
497-
sitk_module=sitk_module,
498-
)
499-
features.update(tumor_features)
500-
if tumor_msg:
501-
messages.append(tumor_msg)
502-
503544
for key, value in features.items():
504545
df.at[idx, key] = value
505546
if features:

tests/test_radiomics.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,19 @@ def test_extract_radiomics_from_dataframe_records_missing_image():
5050
def test_main_writes_output_and_error_csv(tmp_path, monkeypatch):
5151
good_nifti = tmp_path / "good.nii.gz"
5252
bad_nifti = tmp_path / "bad.nii.gz"
53+
good_mask = tmp_path / "good_mask.nii.gz"
54+
bad_mask = tmp_path / "bad_mask.nii.gz"
5355
good_nifti.write_text("nifti")
5456
bad_nifti.write_text("nifti")
57+
good_mask.write_text("mask")
58+
bad_mask.write_text("mask")
5559

5660
csv_path = tmp_path / "nifti_index.csv"
5761
pd.DataFrame(
58-
[{"nifti_path": str(good_nifti)}, {"nifti_path": str(bad_nifti)}]
62+
[
63+
{"nifti_path": str(good_nifti), "mask_liver": str(good_mask)},
64+
{"nifti_path": str(bad_nifti), "mask_liver": str(bad_mask)},
65+
]
5966
).to_csv(csv_path, index=False)
6067

6168
monkeypatch.setattr(
@@ -109,9 +116,13 @@ def fake_liver_minus_tumor(
109116

110117
def test_main_resume_skips_completed_rows(tmp_path, monkeypatch):
111118
good_nifti = tmp_path / "good.nii.gz"
119+
good_mask = tmp_path / "good_mask.nii.gz"
112120
good_nifti.write_text("nifti")
121+
good_mask.write_text("mask")
113122
csv_path = tmp_path / "nifti_index.csv"
114-
pd.DataFrame([{"nifti_path": str(good_nifti)}]).to_csv(csv_path, index=False)
123+
pd.DataFrame(
124+
[{"nifti_path": str(good_nifti), "mask_liver": str(good_mask)}]
125+
).to_csv(csv_path, index=False)
115126

116127
calls = {"count": 0}
117128

0 commit comments

Comments
 (0)