Skip to content

Commit 5452cdd

Browse files
committed
Merge branch 'feat/enhance-radiomic-exctractor'
2 parents 3935778 + 885eb33 commit 5452cdd

6 files changed

Lines changed: 171 additions & 6 deletions

File tree

src/imperandi/extract/phase.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222

2323
logger = logging.getLogger(__name__)
2424
DEFAULT_CHECKPOINT_EVERY_ROWS = 50
25-
DEFAULT_CHECKPOINT_EVERY_SEC = 350
25+
DEFAULT_CHECKPOINT_EVERY_SEC = 5 * 60
2626

2727

2828
def _load_phase_extractor() -> Callable[[Any], Dict[str, Any]]:
@@ -72,6 +72,12 @@ def add_phase_arguments(
7272
default=None,
7373
help="CSV path for failed rows only (default: <csv_dir>/phase_errors.csv).",
7474
)
75+
parser.add_argument(
76+
"--force",
77+
action="store_true",
78+
default=False,
79+
help="Recompute phase even when `totalseg_phase` is already populated.",
80+
)
7581
parser.add_argument("--verbose", "-v", action="store_true", help="Verbose logging")
7682
add_checkpoint_arguments(
7783
parser,
@@ -135,6 +141,14 @@ def parse_arguments() -> argparse.Namespace:
135141
return args
136142

137143

144+
def _has_populated_value(value: Any) -> bool:
145+
if pd.isna(value):
146+
return False
147+
if isinstance(value, str):
148+
return bool(value.strip())
149+
return True
150+
151+
138152
def process_single_volume(
139153
idx: int,
140154
row: Mapping[str, Any],
@@ -212,6 +226,7 @@ def main(args: argparse.Namespace) -> None:
212226

213227
errors_by_idx: Dict[int, Dict[str, Any]] = {}
214228
completed_indices: set[int] = set()
229+
force = bool(getattr(args, "force", False))
215230
if can_resume:
216231
completed_indices = {
217232
int(i)
@@ -227,6 +242,22 @@ def main(args: argparse.Namespace) -> None:
227242
except Exception:
228243
pass
229244

245+
if not force and "totalseg_phase" in df.columns:
246+
prefilled_indices = {
247+
int(df.at[idx, "_source_idx"])
248+
for idx in df.index
249+
if _has_populated_value(df.at[idx, "totalseg_phase"])
250+
}
251+
newly_completed = prefilled_indices - completed_indices
252+
if newly_completed:
253+
completed_indices.update(prefilled_indices)
254+
for source_idx in newly_completed:
255+
errors_by_idx.pop(source_idx, None)
256+
logger.info(
257+
"Skipping %d row(s) with existing totalseg_phase (use --force to recompute)",
258+
len(newly_completed),
259+
)
260+
230261
def _checkpoint_write(*, force: bool = False) -> None:
231262
err_df = (
232263
pd.DataFrame(list(errors_by_idx.values())) if errors_by_idx else pd.DataFrame()

src/imperandi/extract/radiomics.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121

2222
logger = logging.getLogger(__name__)
2323
DEFAULT_CHECKPOINT_EVERY_ROWS = 50
24-
DEFAULT_CHECKPOINT_EVERY_SEC = 350
24+
DEFAULT_CHECKPOINT_EVERY_SEC = 5 * 60
2525

2626
DEFAULT_SETTINGS = {
2727
"binWidth": 25,

src/imperandi/ingest/parse.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,7 @@
4343
warnings.filterwarnings("ignore")
4444
logger = logging.getLogger(__name__)
4545
DEFAULT_CHECKPOINT_EVERY_ROWS = 10_000
46-
DEFAULT_CHECKPOINT_EVERY_SEC = 350
47-
PARSE_WORKER_BATCH_SIZE = 16384
48-
PARSE_CHECKPOINT_SCHEMA_VERSION = 1
46+
DEFAULT_CHECKPOINT_EVERY_SEC = 5 * 60 # 5 minutes
4947

5048
# Make reading tolerant of non-conformant values
5149
config.settings.reading_validation_mode = config.IGNORE # or config.WARN

src/imperandi/process/convert.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,25 @@
3131

3232
logger = logging.getLogger(__name__)
3333
DEFAULT_CHECKPOINT_EVERY_ROWS = 50
34-
DEFAULT_CHECKPOINT_EVERY_SEC = 350
34+
DEFAULT_CHECKPOINT_EVERY_SEC = 5 * 60 # 5 minutes
35+
36+
37+
def _lower_log_level_one_step(level: int) -> int:
38+
if level >= logging.CRITICAL:
39+
return logging.ERROR
40+
if level >= logging.ERROR:
41+
return logging.WARNING
42+
if level >= logging.WARNING:
43+
return logging.INFO
44+
if level >= logging.INFO:
45+
return logging.DEBUG
46+
return logging.DEBUG
47+
48+
49+
def _configure_dicom2nifti_convert_logger(base_logger: logging.Logger) -> None:
50+
base_level = base_logger.getEffectiveLevel()
51+
dicom2nifti_level = _lower_log_level_one_step(base_level)
52+
logging.getLogger("dicom2nifti.convert_dicom").setLevel(dicom2nifti_level)
3553

3654

3755
# Function to parse command-line arguments
@@ -331,6 +349,7 @@ def process_single_volume(k, row, output_dir, verbose, return_status=False):
331349
"converted", "skipped", or "failed".
332350
"""
333351
setup_logging(verbose=verbose)
352+
_configure_dicom2nifti_convert_logger(logger)
334353

335354
def _result(export_path, error_row, status):
336355
if return_status:

tests/test_convert.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import tarfile
44
import zipfile
55
import argparse
6+
import logging
67
from pathlib import Path
78

89
# Ensure src/ is on sys.path for imports
@@ -29,6 +30,40 @@ def test_convert_list_str_to_list_invalid():
2930
assert out == s
3031

3132

33+
@pytest.mark.parametrize(
34+
("base_level", "expected_level"),
35+
[
36+
(logging.CRITICAL, logging.ERROR),
37+
(logging.ERROR, logging.WARNING),
38+
(logging.WARNING, logging.INFO),
39+
(logging.INFO, logging.DEBUG),
40+
(logging.DEBUG, logging.DEBUG),
41+
(logging.NOTSET, logging.DEBUG),
42+
],
43+
)
44+
def test_lower_log_level_one_step(base_level, expected_level):
45+
assert convert_module._lower_log_level_one_step(base_level) == expected_level
46+
47+
48+
def test_configure_dicom2nifti_convert_logger_one_level_lower():
49+
base_logger = logging.getLogger("imperandi.process.convert.test_base")
50+
target_logger = logging.getLogger("dicom2nifti.convert_dicom")
51+
old_base_level = base_logger.level
52+
old_target_level = target_logger.level
53+
54+
try:
55+
base_logger.setLevel(logging.INFO)
56+
convert_module._configure_dicom2nifti_convert_logger(base_logger)
57+
assert target_logger.level == logging.DEBUG
58+
59+
base_logger.setLevel(logging.WARNING)
60+
convert_module._configure_dicom2nifti_convert_logger(base_logger)
61+
assert target_logger.level == logging.INFO
62+
finally:
63+
base_logger.setLevel(old_base_level)
64+
target_logger.setLevel(old_target_level)
65+
66+
3267
def make_series(tmp_path, output_dir, create_nifti=False):
3368
# create a dummy series_dir with files
3469
series_dir = tmp_path / "series"

tests/test_phase.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,88 @@ def fake_load_phase_extractor():
194194
assert extractor_loads["count"] == 1
195195

196196

197+
def test_main_skips_rows_with_existing_totalseg_phase_when_not_forced(
198+
tmp_path, monkeypatch
199+
):
200+
nifti = tmp_path / "valid.nii.gz"
201+
nifti.write_text("nifti")
202+
csv_path = tmp_path / "nifti_index.csv"
203+
pd.DataFrame(
204+
[{"nifti_path": str(nifti), "totalseg_phase": "portal"}]
205+
).to_csv(csv_path, index=False)
206+
207+
calls = {"count": 0}
208+
monkeypatch.setattr(phase_module.nib, "load", lambda _: object())
209+
monkeypatch.setattr(
210+
phase_module,
211+
"_load_phase_extractor",
212+
lambda: (lambda _, quiet=True: {"phase": "arterial"}),
213+
)
214+
215+
def fake_process_single_volume(idx, row, *, phase_extractor, verbose=False):
216+
calls["count"] += 1
217+
return idx, {"totalseg_phase": "arterial"}, None
218+
219+
monkeypatch.setattr(phase_module, "process_single_volume", fake_process_single_volume)
220+
221+
args = argparse.Namespace(
222+
csv_path=str(csv_path),
223+
csv_path_out=str(tmp_path / "out.csv"),
224+
error_csv_path=str(tmp_path / "errors.csv"),
225+
verbose=False,
226+
force=False,
227+
checkpoint_every_rows=1,
228+
checkpoint_every_sec=3600,
229+
resume=False,
230+
strict_resume=False,
231+
)
232+
phase_module.main(args)
233+
234+
assert calls["count"] == 0
235+
out_df = pd.read_csv(args.csv_path_out)
236+
assert out_df.loc[0, "totalseg_phase"] == "portal"
237+
238+
239+
def test_main_force_recomputes_existing_totalseg_phase(tmp_path, monkeypatch):
240+
nifti = tmp_path / "valid.nii.gz"
241+
nifti.write_text("nifti")
242+
csv_path = tmp_path / "nifti_index.csv"
243+
pd.DataFrame(
244+
[{"nifti_path": str(nifti), "totalseg_phase": "portal"}]
245+
).to_csv(csv_path, index=False)
246+
247+
calls = {"count": 0}
248+
monkeypatch.setattr(phase_module.nib, "load", lambda _: object())
249+
monkeypatch.setattr(
250+
phase_module,
251+
"_load_phase_extractor",
252+
lambda: (lambda _, quiet=True: {"phase": "arterial"}),
253+
)
254+
255+
def fake_process_single_volume(idx, row, *, phase_extractor, verbose=False):
256+
calls["count"] += 1
257+
return idx, {"totalseg_phase": "arterial"}, None
258+
259+
monkeypatch.setattr(phase_module, "process_single_volume", fake_process_single_volume)
260+
261+
args = argparse.Namespace(
262+
csv_path=str(csv_path),
263+
csv_path_out=str(tmp_path / "out.csv"),
264+
error_csv_path=str(tmp_path / "errors.csv"),
265+
verbose=False,
266+
force=True,
267+
checkpoint_every_rows=1,
268+
checkpoint_every_sec=3600,
269+
resume=False,
270+
strict_resume=False,
271+
)
272+
phase_module.main(args)
273+
274+
assert calls["count"] == 1
275+
out_df = pd.read_csv(args.csv_path_out)
276+
assert out_df.loc[0, "totalseg_phase"] == "arterial"
277+
278+
197279
def test_main_preserves_foreign_columns_from_existing_output(tmp_path, monkeypatch):
198280
nifti = tmp_path / "valid.nii.gz"
199281
nifti.write_text("nifti")

0 commit comments

Comments
 (0)