diff --git a/cli/medperf/commands/dataset/prepare.py b/cli/medperf/commands/dataset/prepare.py index 30c8fdad8..32ee6def8 100644 --- a/cli/medperf/commands/dataset/prepare.py +++ b/cli/medperf/commands/dataset/prepare.py @@ -165,8 +165,7 @@ def setup_parameters(self): def run_prepare(self): report_sender = ReportSender(self) - if self.allow_sending_reports: - report_sender.start() + report_sender.start() prepare_params = { "data_path": self.raw_data_path, @@ -190,18 +189,15 @@ def run_prepare(self): ) except Exception as e: # Inform the server that a failure occured - if self.allow_sending_reports: - report_sender.stop("failed") + report_sender.stop("failed") raise e - except KeyboardInterrupt: + except KeyboardInterrupt as e: # Inform the server that the process is interrupted - if self.allow_sending_reports: - report_sender.stop("interrupted") - raise + report_sender.stop("interrupted") + raise e self.ui.print("> Cube execution complete") - if self.allow_sending_reports: - report_sender.stop("finished") + report_sender.stop("finished") def run_sanity_check(self): sanity_check_timeout = config.sanity_check_timeout @@ -313,8 +309,12 @@ def prompt_for_report_sending_approval(self): dict_pretty_print(example) msg = ( - " \nDo you approve the automatic submission of summaries similar to the one above" - + " to the MedPerf Server throughout the preparation process?[Y/n]" + "\nYou can decide whether or not to send information about your dataset preparation" + + "\nProgress. Keep in mind that information about the execution status of the pipeline" + + "\nwill be sent regardless (whether the pipeline is running, finished or failed)" + + "\nto identify issues with the preparation procedure. Do you approve the automatic" + + "\nsubmission of summaries similar to the one above to the MedPerf Server throughout" + + "\nthe preparation process?[Y/n]" ) self.allow_sending_reports = approval_prompt(msg) @@ -327,7 +327,12 @@ def send_report(self, report_metadata): return self._send_report(report_metadata) def _send_report(self, report_metadata): - report_status_dict = self.__generate_report_dict() + if self.dataset.for_test: + # Test datasets don't have a registration on the server + return + report_status_dict = {} + if self.allow_sending_reports: + report_status_dict = self.__generate_report_dict() report = {"progress": report_status_dict, **report_metadata} if report == self.dataset.report: # Watchdog may trigger an event even if contents didn't change diff --git a/cli/medperf/tests/commands/dataset/test_prepare.py b/cli/medperf/tests/commands/dataset/test_prepare.py index 0cdefc5d0..e0e9ad6ca 100644 --- a/cli/medperf/tests/commands/dataset/test_prepare.py +++ b/cli/medperf/tests/commands/dataset/test_prepare.py @@ -8,7 +8,7 @@ from medperf.tests.mocks.dataset import TestDataset from medperf.tests.mocks.cube import TestCube -from medperf.commands.dataset.prepare import DataPreparation +from medperf.commands.dataset.prepare import DataPreparation, Observer PATCH_REGISTER = "medperf.commands.dataset.prepare.{}" @@ -54,37 +54,58 @@ def test_get_prep_cube_downloads_cube_file(mocker, data_preparation, cube): spy.assert_called_once() +@pytest.mark.parametrize("dataset_for_test", [False, True]) +def test_prepare_with_test_data_doesnt_send_reports( + mocker, data_preparation, dataset_for_test, cube, comms, fs +): + # Arrange + data_preparation.dataset.for_test = dataset_for_test + mocker.patch.object(cube, "run") + mocker.patch.object(data_preparation.dataset, "write") + mocked_obs = mocker.create_autospec(spec=Observer) + mocker.patch(PATCH_REGISTER.format("Observer", side_effect=mocked_obs)) + send_report_spy = mocker.patch.object(comms, "update_dataset") + + # Act + data_preparation.run_prepare() + + # Assert + if dataset_for_test: + send_report_spy.assert_not_called() + else: + send_report_spy.assert_called() + + @pytest.mark.parametrize("allow_sending_reports", [False, True]) def test_prepare_runs_then_stops_report_handler( - mocker, data_preparation, allow_sending_reports, cube + mocker, data_preparation, allow_sending_reports, cube, comms, fs ): # Arrange data_preparation.allow_sending_reports = allow_sending_reports mocker.patch.object(cube, "run") - start_spy = mocker.patch(PATCH_REGISTER.format("ReportSender.start")) - stop_spy = mocker.patch(PATCH_REGISTER.format("ReportSender.stop")) + mocker.patch.object(data_preparation.dataset, "write") + mocked_obs = mocker.create_autospec(spec=Observer) + mocker.patch(PATCH_REGISTER.format("Observer"), side_effect=mocked_obs) + gen_report_spy = mocker.patch(PATCH_REGISTER.format("DataPreparation._DataPreparation__generate_report_dict")) # Act data_preparation.run_prepare() # Assert if allow_sending_reports: - start_spy.assert_called_once() - stop_spy.assert_called_once_with("finished") + gen_report_spy.assert_called() else: - start_spy.assert_not_called() - stop_spy.assert_not_called() + gen_report_spy.assert_not_called() -@pytest.mark.parametrize("allow_sending_reports", [False, True]) def test_prepare_runs_then_stops_report_handler_on_failure( - mocker, data_preparation, allow_sending_reports, cube + mocker, data_preparation, cube ): # Arrange def _failure_run(*args, **kwargs): raise Exception() - data_preparation.allow_sending_reports = allow_sending_reports + data_preparation.allow_sending_reports = True mocker.patch.object(cube, "run", side_effect=_failure_run) start_spy = mocker.patch(PATCH_REGISTER.format("ReportSender.start")) stop_spy = mocker.patch(PATCH_REGISTER.format("ReportSender.stop")) @@ -94,23 +115,18 @@ def _failure_run(*args, **kwargs): data_preparation.run_prepare() # Assert - if allow_sending_reports: - start_spy.assert_called_once() - stop_spy.assert_called_once_with("failed") - else: - start_spy.assert_not_called() - stop_spy.assert_not_called() + start_spy.assert_called_once() + stop_spy.assert_called_once_with("failed") -@pytest.mark.parametrize("allow_sending_reports", [False, True]) def test_prepare_runs_then_stops_report_handler_on_interrupt( - mocker, data_preparation, allow_sending_reports, cube + mocker, data_preparation, cube ): # Arrange def _failure_run(*args, **kwargs): raise KeyboardInterrupt() - data_preparation.allow_sending_reports = allow_sending_reports + data_preparation.allow_sending_reports = True mocker.patch.object(cube, "run", side_effect=_failure_run) start_spy = mocker.patch(PATCH_REGISTER.format("ReportSender.start")) stop_spy = mocker.patch(PATCH_REGISTER.format("ReportSender.stop")) @@ -120,12 +136,8 @@ def _failure_run(*args, **kwargs): data_preparation.run_prepare() # Assert - if allow_sending_reports: - start_spy.assert_called_once() - stop_spy.assert_called_once_with("interrupted") - else: - start_spy.assert_not_called() - stop_spy.assert_not_called() + start_spy.assert_called_once() + stop_spy.assert_called_once_with("interrupted") @pytest.mark.parametrize("report_specified", [False, True]) diff --git a/cli/requirements.txt b/cli/requirements.txt index 02d8ee05a..94384378c 100644 --- a/cli/requirements.txt +++ b/cli/requirements.txt @@ -22,6 +22,7 @@ setuptools<=66.1.1 email-validator==2.0.0 auth0-python==4.3.0 pandas==2.1.0 +numpy==1.26.4 watchdog==3.0.0 GitPython==3.1.41 psutil==5.9.8 diff --git a/scripts/dashboard/medperf_dashboard/get_data.py b/scripts/dashboard/medperf_dashboard/get_data.py index af8bd89b6..480c981c5 100644 --- a/scripts/dashboard/medperf_dashboard/get_data.py +++ b/scripts/dashboard/medperf_dashboard/get_data.py @@ -1,6 +1,7 @@ import os import pandas as pd import datetime +import numpy as np from medperf.entities.dataset import Dataset from medperf import config @@ -38,6 +39,9 @@ def build_dset_df(dsets, user2institution, stages_df): exec_status = report["execution_status"] formatted_dset["execution_status"] = exec_status formatted_dset["progress"] = report["progress"] + else: + formatted_dset["execution_status"] = np.nan + formatted_dset["progress"] = np.nan formatted_dsets.append(formatted_dset) dsets_df = pd.DataFrame(formatted_dsets) diff --git a/scripts/monitor/rano_monitor/__main__.py b/scripts/monitor/rano_monitor/__main__.py index 1e50d4e4f..37a19e807 100644 --- a/scripts/monitor/rano_monitor/__main__.py +++ b/scripts/monitor/rano_monitor/__main__.py @@ -8,6 +8,8 @@ STAGES_HELP, DSET_LOC_HELP, OUT_HELP, + REVIEW_COMMAND, + REVIEW_CMD_HELP, ) from rano_monitor.dataset_browser import DatasetBrowser from rano_monitor.handlers import InvalidHandler @@ -21,7 +23,7 @@ app = typer.Typer() -def run_dset_app(dset_path, stages_path, output_path): +def run_dset_app(dset_path, stages_path, output_path, review_cmd): report_path = os.path.join(dset_path, "report.yaml") dset_data_path = os.path.join(dset_path, "data") invalid_path = os.path.join(dset_path, "metadata/.invalid.txt") @@ -48,6 +50,7 @@ def run_dset_app(dset_path, stages_path, output_path): invalid_path, invalid_watchdog, prompt_watchdog, + review_cmd, ) observer = Observer() @@ -60,7 +63,7 @@ def run_dset_app(dset_path, stages_path, output_path): observer.stop() -def run_tarball_app(tarball_path): +def run_tarball_app(tarball_path, review_cmd): folder_name = f".{os.path.basename(tarball_path).split('.')[0]}" contents_path = os.path.join(os.path.dirname(tarball_path), folder_name) if not os.path.exists(contents_path): @@ -72,7 +75,7 @@ def run_tarball_app(tarball_path): contents_path = os.path.join(contents_path, "review_cases") reviewed_watchdog = TarballReviewedHandler(contents_path, t_app) - t_app.set_vars(contents_path) + t_app.set_vars(contents_path, review_cmd) observer = Observer() observer.schedule(reviewed_watchdog, path=contents_path, recursive=True) @@ -94,10 +97,13 @@ def main( help=DSET_LOC_HELP, ), output_path: str = Option(None, "-o", "--out", help=OUT_HELP), + itksnap_executable: str = Option( + REVIEW_COMMAND, "-i", "--itksnap", help=REVIEW_CMD_HELP + ), ): if dataset_uid.endswith(".tar.gz"): # TODO: implement tarball_app - run_tarball_app(dataset_uid) + run_tarball_app(dataset_uid, itksnap_executable) return elif dataset_uid.isdigit(): # Only import medperf dependencies if the user intends to use medperf @@ -115,7 +121,7 @@ def main( "Please ensure the passed dataset UID/path is correct" ) - run_dset_app(dset_path, stages_path, output_path) + run_dset_app(dset_path, stages_path, output_path, itksnap_executable) if __name__ == "__main__": diff --git a/scripts/monitor/rano_monitor/assets/shared.tcss b/scripts/monitor/rano_monitor/assets/shared.tcss index 8bc76bd06..c0b25b565 100644 --- a/scripts/monitor/rano_monitor/assets/shared.tcss +++ b/scripts/monitor/rano_monitor/assets/shared.tcss @@ -1,3 +1,11 @@ .warning { border: tall $warning; } + +.tumor-status { + color: $success; +} + +.brain-status { + color: $warning; +} diff --git a/scripts/monitor/rano_monitor/assets/tarball-browser.tcss b/scripts/monitor/rano_monitor/assets/tarball-browser.tcss index 55b21a827..709698a7f 100644 --- a/scripts/monitor/rano_monitor/assets/tarball-browser.tcss +++ b/scripts/monitor/rano_monitor/assets/tarball-browser.tcss @@ -16,14 +16,6 @@ Button { background: $accent; } -.tumor-status { - color: $success; -} - -.brain-status { - color: $warning; -} - #package-btn { min-width: 50%; } diff --git a/scripts/monitor/rano_monitor/constants.py b/scripts/monitor/rano_monitor/constants.py index 7330ce36c..2515cde27 100644 --- a/scripts/monitor/rano_monitor/constants.py +++ b/scripts/monitor/rano_monitor/constants.py @@ -24,6 +24,7 @@ REVIEW_FILENAME = "review_cases.tar.gz" REVIEWED_FILENAME = "reviewed_cases.tar.gz" REVIEW_COMMAND = "itksnap" +REVIEW_CMD_HELP = f"path or name of the command that launches itksnap. Defaults to '{REVIEW_COMMAND}'" MANUAL_REVIEW_STAGE = 5 DONE_STAGE = 8 LISTITEM_MAX_LEN = 30 diff --git a/scripts/monitor/rano_monitor/dataset_browser.py b/scripts/monitor/rano_monitor/dataset_browser.py index 3f75a092f..47bd12b54 100644 --- a/scripts/monitor/rano_monitor/dataset_browser.py +++ b/scripts/monitor/rano_monitor/dataset_browser.py @@ -5,6 +5,7 @@ import yaml from rano_monitor.messages import InvalidSubjectsUpdated from rano_monitor.messages import ReportUpdated +from rano_monitor.messages import AnnotationsLoaded from rano_monitor.utils import generate_full_report from rano_monitor.widgets.subject_details import SubjectDetails from rano_monitor.widgets.subject_list_view import SubjectListView @@ -19,6 +20,7 @@ Header, ListView, Static, + Input, ) @@ -31,6 +33,7 @@ class DatasetBrowser(App): Binding("y", "respond('y')", "Yes", show=False), Binding("n", "respond('n')", "No", show=False), ] + AUTO_FOCUS = "" # Don't focus automatically to search bar subjects = var([]) report = reactive({}) @@ -46,6 +49,7 @@ def set_vars( invalid_path, invalid_watchdog, prompt_watchdog, + review_cmd, ): self.dset_data_path = dset_data_path self.stages_path = stages_path @@ -53,6 +57,7 @@ def set_vars( self.invalid_path = invalid_path self.invalid_watchdog = invalid_watchdog self.prompt_watchdog = prompt_watchdog + self.review_cmd = review_cmd def update_invalid(self, invalid_subjects): self.invalid_subjects = invalid_subjects @@ -62,6 +67,7 @@ def compose(self) -> ComposeResult: yield Header() with Container(): with Container(id="list-container"): + yield Input(placeholder="Search", id="subjects-search") yield SubjectListView(id="subjects-list") with VerticalScroll(): yield Summary(id="summary") @@ -103,6 +109,11 @@ def on_mount(self): # Set invalid path for subject view subject_details = self.query_one("#details", SubjectDetails) subject_details.set_invalid_path(self.invalid_path) + subject_details.review_cmd = self.review_cmd + + # Set dataset path to listview + listview = self.query_one("#subjects-list", ListView) + listview.dset_path = self.dset_data_path # Execute handlers self.prompt_watchdog.manual_execute() @@ -143,6 +154,17 @@ def on_button_pressed(self, event: Button.Pressed) -> None: elif event.control == n_button: self.action_respond("n") + def on_input_changed(self, event: Input.Changed) -> None: + search_input = self.query_one("#subjects-search") + subjects_list = self.query_one("#subjects-list") + if event.control == search_input: + search_term = search_input.value + subjects_list.update_list(search_term) + + def on_annotations_loaded(self, message: AnnotationsLoaded): + subjects_list = self.query_one("#subjects-list") + subjects_list.update_list() + def update_prompt(self, prompt: str): self.prompt = prompt show_prompt = bool(len(prompt)) diff --git a/scripts/monitor/rano_monitor/messages/__init__.py b/scripts/monitor/rano_monitor/messages/__init__.py index 2abb1e07f..5d88cbf2d 100644 --- a/scripts/monitor/rano_monitor/messages/__init__.py +++ b/scripts/monitor/rano_monitor/messages/__init__.py @@ -1,4 +1,5 @@ from .invalid_subject_updated import InvalidSubjectsUpdated from .report_updated import ReportUpdated +from .annotations_loaded import AnnotationsLoaded -__all__ = [InvalidSubjectsUpdated, ReportUpdated] +__all__ = [InvalidSubjectsUpdated, ReportUpdated, AnnotationsLoaded] diff --git a/scripts/monitor/rano_monitor/messages/annotations_loaded.py b/scripts/monitor/rano_monitor/messages/annotations_loaded.py new file mode 100644 index 000000000..e5d080f6d --- /dev/null +++ b/scripts/monitor/rano_monitor/messages/annotations_loaded.py @@ -0,0 +1,5 @@ +from textual.message import Message + + +class AnnotationsLoaded(Message): + pass diff --git a/scripts/monitor/rano_monitor/tarball_browser.py b/scripts/monitor/rano_monitor/tarball_browser.py index 4c81aaa60..ebea23d0f 100644 --- a/scripts/monitor/rano_monitor/tarball_browser.py +++ b/scripts/monitor/rano_monitor/tarball_browser.py @@ -44,8 +44,9 @@ class TarballBrowser(App): subjects = var([]) - def set_vars(self, contents_path): + def set_vars(self, contents_path, review_cmd): self.contents_path = contents_path + self.review_cmd = review_cmd self.subjects_list = self.__get_subjects() def __get_subjects(self): @@ -55,7 +56,9 @@ def __get_subjects(self): for subject in subjects: subject_path = os.path.join(self.contents_path, subject) timepoints = os.listdir(subject_path) - timepoints = [timepoint for timepoint in timepoints if not timepoint.startswith(".")] + timepoints = [ + timepoint for timepoint in timepoints if not timepoint.startswith(".") + ] subject_timepoint_list += [(subject, tp) for tp in timepoints] return subject_timepoint_list @@ -76,6 +79,7 @@ def compose(self) -> ComposeResult: subject_view = TarballSubjectView() subject_view.subject = f"{id}|{tp}" subject_view.contents_path = self.contents_path + subject_view.review_cmd = self.review_cmd yield subject_view def on_mount(self): @@ -84,7 +88,7 @@ def on_mount(self): def update_subjects_status(self): subject_views = self.query(TarballSubjectView) editor_msg = self.query_one("#review-msg", Static) - editor_msg.display = "none" if is_editor_installed() else "block" + editor_msg.display = "none" if is_editor_installed(self.review_cmd) else "block" for subject_view in subject_views: subject_view.update_status() diff --git a/scripts/monitor/rano_monitor/utils.py b/scripts/monitor/rano_monitor/utils.py index e0e56d30a..1c61ea97f 100644 --- a/scripts/monitor/rano_monitor/utils.py +++ b/scripts/monitor/rano_monitor/utils.py @@ -9,7 +9,6 @@ import pandas as pd import yaml from rano_monitor.constants import ( - REVIEW_COMMAND, BRAINMASK_BAK, DEFAULT_SEGMENTATION, BRAINMASK, @@ -22,8 +21,8 @@ ) -def is_editor_installed(): - review_command_path = shutil.which(REVIEW_COMMAND) +def is_editor_installed(review_command): + review_command_path = shutil.which(review_command) return review_command_path is not None @@ -34,10 +33,10 @@ def get_hash(filepath: str): return file_hash -def run_editor(t1c, flair, t2, t1, seg, label, cmd=REVIEW_COMMAND): +def run_editor(t1c, flair, t2, t1, seg, label, cmd): review_cmd = "{cmd} -g {t1c} -o {flair} {t2} {t1} -s {seg} -l {label}" review_cmd = review_cmd.format( - cmd=REVIEW_COMMAND, + cmd=cmd, t1c=t1c, flair=flair, t2=t2, @@ -48,7 +47,9 @@ def run_editor(t1c, flair, t2, t1, seg, label, cmd=REVIEW_COMMAND): Popen(review_cmd.split(), shell=False, stdout=DEVNULL, stderr=DEVNULL) -def review_tumor(subject: str, data_path: str, labels_path: str): +def review_tumor( + subject: str, data_path: str, labels_path: str, review_cmd: str +): ( t1c_file, t1n_file, @@ -65,10 +66,18 @@ def review_tumor(subject: str, data_path: str, labels_path: str): if not is_nifti and not is_under_review: shutil.copyfile(seg_file, under_review_file) - run_editor(t1c_file, t2f_file, t2w_file, t1n_file, under_review_file, label_file) + run_editor( + t1c_file, + t2f_file, + t2w_file, + t1n_file, + under_review_file, + label_file, + cmd=review_cmd, + ) -def review_brain(subject, labels_path, data_path=None): +def review_brain(subject, labels_path, review_cmd, data_path=None): ( t1c_file, t1n_file, @@ -82,7 +91,9 @@ def review_brain(subject, labels_path, data_path=None): if not os.path.exists(backup_path): shutil.copyfile(seg_file, backup_path) - run_editor(t1c_file, t2f_file, t2w_file, t1n_file, seg_file, label_file) + run_editor( + t1c_file, t2f_file, t2w_file, t1n_file, seg_file, label_file, cmd=review_cmd + ) def finalize(subject: str, labels_path: str): @@ -364,12 +375,6 @@ def unpackage_reviews(file, app, dset_data_path): identified_masks ) - if len(identified_reviewed): - app.notify("Reviewed cases identified") - - if len(identified_brainmasks): - app.notify("Brain masks identified") - extracts = get_identified_extract_paths( identified_reviewed, identified_under_review, @@ -387,3 +392,58 @@ def unpackage_reviews(file, app, dset_data_path): if os.path.exists(target_file): delete(target_file, dset_data_path) tar.extract(member, dest) + + +def brain_has_been_reviewed(brainpath, backup_brainpath): + if not os.path.exists(backup_brainpath): + return False + + brain_hash = get_hash(brainpath) + backup_hash = get_hash(backup_brainpath) + return brain_hash != backup_hash + + +def tumor_has_been_finalized(finalized_tumor_path): + finalized_files = os.listdir(finalized_tumor_path) + finalized_files = [file for file in finalized_files if not file.startswith(".")] + + return len(finalized_files) > 0 + + +def can_review(subject): + return MANUAL_REVIEW_STAGE <= abs(subject["status"]) < DONE_STAGE + + +def get_finalized_tumor_path(subject: str, dset_path: str) -> str: + """Get's the path to the finalized tumor path based solely on the + subject identifier and data path. Works regardless of wether the subject is in + that stage or the folder being pointed to exists or not. + + Args: + subject (str): subject identified, written as {subject}|{timepoint} + + Returns: + str: _description_ + """ + id, tp = subject.split("|") + return os.path.join( + dset_path, + "tumor_extracted", + "DataForQC", + id, + tp, + "TumorMasksForQC", + "finalized", + ) + + +def get_brainmask_path(subject: str, dset_path: str) -> str: + id, tp = subject.split("|") + return os.path.join( + dset_path, + "tumor_extracted", + "DataForQC", + id, + tp, + BRAINMASK, + ) diff --git a/scripts/monitor/rano_monitor/widgets/subject_details.py b/scripts/monitor/rano_monitor/widgets/subject_details.py index 27f2aee22..eea9b2695 100644 --- a/scripts/monitor/rano_monitor/widgets/subject_details.py +++ b/scripts/monitor/rano_monitor/widgets/subject_details.py @@ -3,7 +3,6 @@ import pandas as pd from rano_monitor.constants import ( DEFAULT_SEGMENTATION, - DONE_STAGE, MANUAL_REVIEW_STAGE, ) from rano_monitor.messages import InvalidSubjectsUpdated @@ -14,6 +13,7 @@ review_brain, review_tumor, to_local_path, + can_review, ) from rano_monitor.widgets.copyable_item import CopyableItem from textual.app import ComposeResult @@ -25,6 +25,7 @@ class SubjectDetails(Static): invalid_subjects = set() subject = pd.Series() dset_path = "" + review_cmd = None # This will be assigned after initialized def compose(self) -> ComposeResult: with Center(id="subject-title"): @@ -51,10 +52,7 @@ def compose(self) -> ComposeResult: id="reviewed-button", disabled=True, ) - yield Static( - "If brain mask is not correct", - id="brianmask-review-header" - ) + yield Static("If brain mask is not correct", id="brianmask-review-header") yield Button( "Brain mask not available", disabled=True, @@ -118,8 +116,7 @@ def update_subject(self): # This SHOULD NOT be here for general data prep monitoring. # Additional configuration must be set # to make this kind of features generic - can_review = MANUAL_REVIEW_STAGE <= abs(subject["status"]) < DONE_STAGE - buttons_container.display = "block" if can_review else "none" + buttons_container.display = "block" if can_review(subject) else "none" # Only display finalize button for the manual review can_finalize = abs(subject["status"]) == MANUAL_REVIEW_STAGE @@ -135,7 +132,7 @@ def __update_buttons(self): brainmask_button = self.query_one("#brainmask-review-button", Button) valid_btn = self.query_one("#valid-btn", Button) - if is_editor_installed(): + if is_editor_installed(self.review_cmd): review_msg.display = "none" review_button.disabled = False if self.__can_finalize(): @@ -176,7 +173,7 @@ def __review_tumor(self): data_path = to_local_path(data_path, self.dset_path) labels_path = self.subject["labels_path"] labels_path = to_local_path(labels_path, self.dset_path) - review_tumor(subject, data_path, labels_path) + review_tumor(subject, data_path, labels_path, review_cmd=self.review_cmd) self.__update_buttons() self.notify("This subject can be finalized now") @@ -184,7 +181,7 @@ def __review_brainmask(self): subject = self.subject.name labels_path = self.subject["labels_path"] labels_path = to_local_path(labels_path, self.dset_path) - review_brain(subject, labels_path) + review_brain(subject, labels_path, review_cmd=self.review_cmd) self.__update_buttons() def __finalize(self): diff --git a/scripts/monitor/rano_monitor/widgets/subject_list_view.py b/scripts/monitor/rano_monitor/widgets/subject_list_view.py index 57e599808..2b8759c48 100644 --- a/scripts/monitor/rano_monitor/widgets/subject_list_view.py +++ b/scripts/monitor/rano_monitor/widgets/subject_list_view.py @@ -1,13 +1,21 @@ +import os import pandas as pd from rano_monitor.messages import InvalidSubjectsUpdated from rano_monitor.messages.report_updated import ReportUpdated from textual.widgets import Label, ListItem, ListView +from rano_monitor.utils import ( + get_hash, + tumor_has_been_finalized, + get_finalized_tumor_path, + get_brainmask_path, +) class SubjectListView(ListView): report = {} highlight = set() invalid_subjects = set() + dset_path = "" def on_report_updated(self, message: ReportUpdated) -> None: self.report = message.report @@ -16,14 +24,11 @@ def on_report_updated(self, message: ReportUpdated) -> None: if len(self.report) > 0: self.update_list() - def on_invalid_subjects_updated( - self, - message: InvalidSubjectsUpdated - ) -> None: + def on_invalid_subjects_updated(self, message: InvalidSubjectsUpdated) -> None: self.invalid_subjects = message.invalid_subjects self.update_list() - def update_list(self): + def update_list(self, search_term=""): # Check for content differences with old report # apply alert class to listitem report = self.report @@ -40,12 +45,40 @@ def update_list(self): status = status.capitalize().replace("_", " ") if subject in self.invalid_subjects: status = "Invalidated" - widget = ListItem( + + list_contents = [ Label(subject), Label(status, classes="subtitle"), - ) + ] + + tumor_path = get_finalized_tumor_path(subject, self.dset_path) + if os.path.exists(tumor_path) and tumor_has_been_finalized(tumor_path): + list_contents.append( + Label("Tumor finalized", classes="tumor-status") + ) + + brain_path = get_brainmask_path(subject, self.dset_path) + exp_hash = report_df.loc[subject]["brain_mask_hash"] + if os.path.exists(brain_path) and get_hash(brain_path) != exp_hash: + list_contents.append( + Label("Brain Mask Modified", classes="brain-status") + ) + + widget = ListItem(*list_contents) + if subject in self.highlight: widget.set_class(True, "highlight") + + should_display = True + if search_term != "": + should_display = ( + subject == "SUMMARY" + or search_term.lower() in subject.lower() + or search_term.lower() in status.lower() + ) + + if not should_display: + continue widgets.append(widget) current_idx = self.index diff --git a/scripts/monitor/rano_monitor/widgets/summary.py b/scripts/monitor/rano_monitor/widgets/summary.py index 9f3f2e7b1..34cf22409 100644 --- a/scripts/monitor/rano_monitor/widgets/summary.py +++ b/scripts/monitor/rano_monitor/widgets/summary.py @@ -1,8 +1,9 @@ import os import pandas as pd -from rano_monitor.constants import REVIEW_FILENAME, REVIEWED_FILENAME +from rano_monitor.constants import REVIEW_FILENAME, REVIEWED_FILENAME, MANUAL_REVIEW_STAGE, DONE_STAGE from rano_monitor.messages import InvalidSubjectsUpdated from rano_monitor.messages import ReportUpdated +from rano_monitor.messages import AnnotationsLoaded from rano_monitor.utils import package_review_cases, unpackage_reviews from textual.app import ComposeResult from textual.containers import Center @@ -23,6 +24,11 @@ class Summary(Static): def compose(self) -> ComposeResult: yield Static("Report Status") + yield Static( + "HINT: To move forward with processing and finalized annotations, ensure the preparation pipeline is running.", + id="hint-msg", + classes="warning", + ) yield Center(id="summary-content") with Center(id="package-btns"): yield Button( @@ -60,11 +66,17 @@ def update_summary(self): # Attach status_percents["DONE"] = 0.0 - package_btns.display = "MANUAL_REVIEW_REQUIRED" in status_percents + abs_status = display_report_df["status"].abs() + is_beyond_manual_review = (abs_status >= MANUAL_REVIEW_STAGE) + is_not_done = (abs_status < DONE_STAGE) + package_btns.display = any(is_beyond_manual_review & is_not_done) widgets = [] for name, val in status_percents.items(): - wname = Label(name.capitalize().replace("_", " ")) + count = status_counts[name] if name in status_counts else 0 + wname = Label( + f'{name.capitalize().replace("_", " ")} ({count}/{len(report_df)})' + ) wpbar = ProgressBar(total=1, show_eta=False) wpbar.advance(val) widget = Center(wname, wpbar, classes="pbar") @@ -77,17 +89,39 @@ def update_summary(self): content.mount(*widgets) - def on_button_pressed(self, event: Button.Pressed) -> None: + async def _package_review_cases(self): + pkg_btn = self.query_one("#package-btn", Button) + label = pkg_btn.label + pkg_btn.disabled = True + pkg_btn.label = "Creating package..." + self.notify("Packaging review cases. This may take a while") + package_review_cases(self.report, self.dset_path) + self.notify(f"{REVIEW_FILENAME} was created on the working directory") + pkg_btn.label = label + pkg_btn.disabled = False + + async def _unpackage_reviews(self): + unpkg_btn = self.query_one("#unpackage-btn", Button) + label = unpkg_btn.label + unpkg_btn.disabled = True + unpkg_btn.label = "Loading annotations..." + self.notify("Loading annotations. This may take a while") + unpackage_reviews(REVIEWED_FILENAME, self, self.dset_path) + self.notify("Annotations have been loaded") + unpkg_btn.label = label + unpkg_btn.disabled = False + self.post_message(AnnotationsLoaded()) + + async def on_button_pressed(self, event: Button.Pressed) -> None: event.stop() pkg_btn = self.query_one("#package-btn", Button) unpkg_btn = self.query_one("#unpackage-btn", Button) if event.control == pkg_btn: - package_review_cases(self.report, self.dset_path) - self.notify(f"{REVIEW_FILENAME} was created on the working directory") + self.run_worker(self._package_review_cases(), exclusive=True, thread=True) elif event.control == unpkg_btn: if REVIEWED_FILENAME not in os.listdir("."): self.notify(f"{REVIEWED_FILENAME} not found in {os.path.abspath('.')}") return - unpackage_reviews(REVIEWED_FILENAME, self, self.dset_path) + self.run_worker(self._unpackage_reviews(), exclusive=True, thread=True) diff --git a/scripts/monitor/rano_monitor/widgets/tarball_subject_view.py b/scripts/monitor/rano_monitor/widgets/tarball_subject_view.py index 8f939c20a..d61f8cf5f 100644 --- a/scripts/monitor/rano_monitor/widgets/tarball_subject_view.py +++ b/scripts/monitor/rano_monitor/widgets/tarball_subject_view.py @@ -1,16 +1,13 @@ import os -from rano_monitor.constants import ( - BRAINMASK, - BRAINMASK_BAK, - DEFAULT_SEGMENTATION -) +from rano_monitor.constants import BRAINMASK, BRAINMASK_BAK, DEFAULT_SEGMENTATION from rano_monitor.utils import ( finalize, - get_hash, is_editor_installed, review_brain, review_tumor, + brain_has_been_reviewed, + tumor_has_been_finalized, ) from textual.app import ComposeResult from textual.containers import Container, Horizontal @@ -21,16 +18,14 @@ class TarballSubjectView(Static): subject = reactive("") contents_path = reactive("") + review_cmd = None # This will be assigned after initialized def compose(self) -> ComposeResult: with Horizontal(classes="subject-item"): with Container(classes="subject-text"): yield Static(self.subject) yield Static("Brain mask modified", classes="brain-status") - yield Static( - "Tumor segmentation reviewed", - classes="tumor-status" - ) + yield Static("Tumor segmentation reviewed", classes="tumor-status") yield Button("Review Brain Mask", classes="brain-btn") yield Button("Review Tumor Segmentation", classes="tumor-btn") @@ -55,16 +50,22 @@ def on_mount(self): def update_status(self): tumor_status = self.query_one(".tumor-status", Static) brain_status = self.query_one(".brain-status", Static) - if self.__tumor_has_been_finalized(): + + id, tp = self.subject.split("|") + finalized_tumor_path = os.path.join(self.contents_path, id, tp, "finalized") + brainpath = os.path.join(self.contents_path, id, tp, BRAINMASK) + backup_brainpath = os.path.join(self.contents_path, id, tp, BRAINMASK_BAK) + + if tumor_has_been_finalized(finalized_tumor_path): tumor_status.display = "block" - if self.__brain_has_been_reviewed(): + if brain_has_been_reviewed(brainpath, backup_brainpath): brain_status.display = "block" def __update_buttons(self): tumor_btn = self.query_one(".tumor-btn", Button) finalize_btn = self.query_one(".finalize-btn", Button) brain_btn = self.query_one(".brain-btn", Button) - if is_editor_installed(): + if is_editor_installed(self.review_cmd): tumor_btn.disabled = False if self.__can_finalize(): finalize_btn.disabled = False @@ -87,19 +88,19 @@ def __can_review_brain(self): id, tp = self.subject.split("|") filepath = os.path.join(self.contents_path, id, tp, BRAINMASK) - return os.path.exists(filepath) and is_editor_installed() + return os.path.exists(filepath) and is_editor_installed(self.review_cmd) def __review_tumor(self): id, tp = self.subject.split("|") data_path = os.path.join(self.contents_path, id, tp, "brain_scans") labels_path = os.path.join(self.contents_path, id, tp) - review_tumor(self.subject, data_path, labels_path) + review_tumor(self.subject, data_path, labels_path, review_cmd=self.review_cmd) def __review_brainmask(self): id, tp = self.subject.split("|") data_path = os.path.join(self.contents_path, id, tp, "raw_scans") labels_path = os.path.join(self.contents_path, id, tp) - review_brain(self.subject, labels_path, data_path) + review_brain(self.subject, labels_path, self.review_cmd, data_path) def __finalize(self): id, tp = self.subject.split("|") @@ -123,12 +124,7 @@ def on_button_pressed(self, event: Button.Pressed) -> None: def __brain_has_been_reviewed(self): id, tp = self.subject.split("|") brainpath = os.path.join(self.contents_path, id, tp, BRAINMASK) - backup_brainpath = os.path.join( - self.contents_path, - id, - tp, - BRAINMASK_BAK - ) + backup_brainpath = os.path.join(self.contents_path, id, tp, BRAINMASK_BAK) if not os.path.exists(backup_brainpath): return False @@ -139,12 +135,7 @@ def __brain_has_been_reviewed(self): def __tumor_has_been_finalized(self): id, tp = self.subject.split("|") - finalized_tumor_path = os.path.join( - self.contents_path, - id, - tp, - "finalized" - ) + finalized_tumor_path = os.path.join(self.contents_path, id, tp, "finalized") finalized_files = os.listdir(finalized_tumor_path) finalized_files = [file for file in finalized_files if not file.startswith(".")] diff --git a/scripts/monitor/setup.py b/scripts/monitor/setup.py index a70cc176f..8c31a159d 100644 --- a/scripts/monitor/setup.py +++ b/scripts/monitor/setup.py @@ -9,7 +9,7 @@ setup( name="rano-monitor", - version="0.0.1", + version="0.0.2", description="TUI for monitoring medperf datasets", url="https://github.com/mlcommons/medperf", author="MLCommons",