From e7048eb55804ca5ac97403fb3e480d995844dc6d Mon Sep 17 00:00:00 2001 From: Reza Asakereh Date: Thu, 1 Feb 2024 20:11:37 -0500 Subject: [PATCH] Update added, fixed non-square image issue --- MedSAM/MedSAMLite/MedSAMLite.py | 110 +++++++++++++++---- MedSAM/MedSAMLite/Resources/UI/MedSAMLite.ui | 21 ++-- README.md | 6 +- server/server.py | 4 +- server_essentials.zip | Bin 36888292 -> 36888326 bytes 5 files changed, 110 insertions(+), 31 deletions(-) diff --git a/MedSAM/MedSAMLite/MedSAMLite.py b/MedSAM/MedSAMLite/MedSAMLite.py index 873ab24..40fb513 100644 --- a/MedSAM/MedSAMLite/MedSAMLite.py +++ b/MedSAM/MedSAMLite/MedSAMLite.py @@ -35,6 +35,8 @@ except: pass # no installation anymore, shorter plugin load +MEDSAMLITE_VERSION = 'v0.03' + # # MedSAMLite # @@ -181,18 +183,22 @@ def setup(self) -> None: DEPENDENCIES_AVAILABLE = False if not DEPENDENCIES_AVAILABLE: - from PythonQt.QtGui import QLabel, QPushButton, QSpacerItem, QSizePolicy + from PythonQt.QtGui import QLabel, QPushButton, QSpacerItem, QSizePolicy, QCheckBox import ctk path_instruction = QLabel('Choose a folder to install module dependencies in') restart_instruction = QLabel('Restart 3D Slicer after all dependencies are installed!') ctk_install_path = ctk.ctkPathLineEdit() ctk_install_path.filters = ctk.ctkPathLineEdit.Dirs + + local_install = QCheckBox("Install from local server_essentials.zip") + local_install.toggled.connect(lambda:self.toggleLocalInstall(local_install, ctk_install_path)) install_btn = QPushButton('Install dependencies') install_btn.clicked.connect(lambda: self.logic.install_dependencies(ctk_install_path)) self.layout.addWidget(path_instruction) + self.layout.addWidget(local_install) self.layout.addWidget(ctk_install_path) self.layout.addWidget(install_btn) self.layout.addWidget(restart_instruction) @@ -233,6 +239,7 @@ def setup(self) -> None: self.addObserver(slicer.mrmlScene, slicer.mrmlScene.EndCloseEvent, self.onSceneEndClose) # Buttons + self.ui.pbUpgrade.connect('clicked(bool)', lambda: self.logic.run_on_background(self.logic.upgrade, (True,), 'Checking for updates...')) self.ui.pbSendImage.connect('clicked(bool)', lambda: self.logic.sendImage()) self.ui.pbSegment.connect('clicked(bool)', lambda: self.logic.applySegmentation()) @@ -289,6 +296,15 @@ def makeROI2D(self): roiNode.SetSize(roi_size[0], roi_size[1], 1) roi_center = np.array(roiNode.GetCenter()) roiNode.SetCenter([roi_center[0], roi_center[1], slicer.app.layoutManager().sliceWidget("Red").sliceLogic().GetSliceOffset()]) + + def toggleLocalInstall(self, checkbox, file_selector): + import ctk + file_selector.currentPath = '' + if checkbox.isChecked(): + file_selector.filters = ctk.ctkPathLineEdit.Files + file_selector.nameFilters = ['server_essentials.zip'] + else: + file_selector.filters = ctk.ctkPathLineEdit.Dirs def cleanup(self) -> None: @@ -454,18 +470,19 @@ def pip_install_wrapper(self, command, event): slicer.util.pip_install(command) event.set() - def download_wrapper(self, url, filename, event): - with urlopen(url) as r: - # self.setTotalProgress.emit(int(r.info()["Content-Length"])) - with open(filename, "wb") as f: - while True: - chunk = r.read(1024) - if chunk is None: - continue - elif chunk == b"": - break - f.write(chunk) - + def download_wrapper(self, url, filename, download_needed, event): + if download_needed: + with urlopen(url) as r: + # self.setTotalProgress.emit(int(r.info()["Content-Length"])) + with open(filename, "wb") as f: + while True: + chunk = r.read(1024) + if chunk is None: + continue + elif chunk == b"": + break + f.write(chunk) + with zipfile.ZipFile(filename, 'r') as zip_ref: zip_ref.extractall(os.path.dirname(filename)) @@ -476,15 +493,26 @@ def install_dependencies(self, ctk_path): print('Installation path is empty') return - print('Installation will happen in %s'%ctk_path.currentPath) - self.widget.write_setting(ctk_path.currentPath) + if os.path.isfile(ctk_path.currentPath) and os.path.basename(ctk_path.currentPath) == 'server_essentials.zip': + install_path = os.path.abspath(os.path.dirname(ctk_path.currentPath)) + download_needed = False + elif os.path.isdir(ctk_path.currentPath): + install_path = ctk_path.currentPath + download_needed = True + else: + print('Invalid installation path') + return + + print('Installation will happen in %s'%install_path) + self.widget.write_setting(install_path) + file_url = 'https://github.com/rasakereh/medsam-3dslicer/raw/master/server_essentials.zip' - filename = os.path.join(ctk_path.currentPath, 'server_essentials.zip') + filename = os.path.join(install_path, 'server_essentials.zip') - self.run_on_background(self.download_wrapper, (file_url, filename), 'Downloading additional files...') + self.run_on_background(self.download_wrapper, (file_url, filename, download_needed), 'Downloading additional files...') - self.server_dir = os.path.join(ctk_path.currentPath + '/', 'server_essentials') + self.server_dir = os.path.join(install_path + '/', 'server_essentials') dependencies = { 'PyTorch': 'torch==2.0.1 torchvision==0.15.2', @@ -498,6 +526,41 @@ def install_dependencies(self, ctk_path): self.run_on_background(self.pip_install_wrapper, (dependencies[dependency],), 'Installing dependencies: %s'%dependency) + def upgrade(self, download, event): + try: + self.progressbar.setLabelText('Checking for updates...') + latest_version_req = requests.get('https://github.com/bowang-lab/MedSAMSlicer/releases/latest') + latest_version = latest_version_req.url.split('/')[-1] + latest_version = float(latest_version[1:]) + curr_version = float(MEDSAMLITE_VERSION[1:]) + print('Latest version identified:', latest_version) + print('Current version is:', curr_version) + if latest_version > curr_version: + print('Upgrade available') + github_base = 'https://raw.githubusercontent.com/bowang-lab/MedSAMSlicer/v%.2f/'%latest_version + server_url = github_base + 'server/server.py' + module_url = github_base + 'MedSAM/MedSAMLite/MedSAMLite.py' + + self.progressbar.setLabelText('Downloading updates...') + server_req = requests.get(server_url) + module_req = requests.get(module_url) + + with open(os.path.join(self.server_dir, 'server.py'), 'w') as server_file: + server_file.write(server_req.text) + with open(__file__, 'w') as module_file: + module_file.write(module_req.text) + self.progressbar.setLabelText('Upgraded successfully, please restart Slicer.') + + else: + self.progressbar.setLabelText('Already using the latest version') + except: + self.progressbar.setLabelText('Error happened while upgrading') + + time.sleep(3) + + event.set() + + def run_on_background(self, target, args, title): self.progressbar = slicer.util.createProgressDialog(autoClose=False) self.progressbar.minimum = 0 @@ -597,6 +660,11 @@ def sendImage(self, serverUrl='http://127.0.0.1:5555', numpyServerAddress=("127. def inferSegmentation(self, serverUrl='http://127.0.0.1:5555'): print('sending infer request...') + ################ DEBUG MODE ################ + if self.volume_node is None: + self.captureImage() + ################ DEBUG MODE ################ + slice_idx, bbox, zrange = self.get_bounding_box() response = requests.post(f'{serverUrl}/infer', json={"slice_idx": slice_idx, "bbox": bbox, "zrange": zrange}) @@ -671,8 +739,7 @@ def get_bounding_box(self): return slice_idx, bbox, zrange def preprocess_CT(self, win_level=40.0, win_width=400.0): - if self.image_data is None: - self.captureImage() + self.captureImage() # self.volume_node.GetDisplayNode().SetThreshold(0, 255) # self.volume_node.GetDisplayNode().ApplyThresholdOn() @@ -687,8 +754,7 @@ def preprocess_CT(self, win_level=40.0, win_width=400.0): return image_data_pre def preprocess_MR(self, lower_percent=0.5, upper_percent=99.5): - if self.image_data is None: - self.captureImage() + self.captureImage() lower_bound, upper_bound = np.percentile(self.image_data[self.image_data > 0], lower_percent), np.percentile(self.image_data[self.image_data > 0], upper_percent) image_data_pre = np.clip(self.image_data, lower_bound, upper_bound) diff --git a/MedSAM/MedSAMLite/Resources/UI/MedSAMLite.ui b/MedSAM/MedSAMLite/Resources/UI/MedSAMLite.ui index a5b991d..631acac 100644 --- a/MedSAM/MedSAMLite/Resources/UI/MedSAMLite.ui +++ b/MedSAM/MedSAMLite/Resources/UI/MedSAMLite.ui @@ -11,19 +11,19 @@ + + + + Upgrade Module + + + Prepare Data - - - - Preprocessing Options: - - - @@ -56,6 +56,13 @@ + + + + Preprocessing Options: + + + diff --git a/README.md b/README.md index 40ad6f4..40cd33e 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,11 @@ You can watch a video tutorial of installation steps [here](https://youtu.be/qjs 6. `Choose a folder` to install module dependencies and click on `Install dependencies`. It can take several minutes. 7. Restart 3D Slicer. -**To update to a newer version:** Remove all pre-existing files from both step#2 and step#6 and install the new version as instructed before. +## Upgrade + +**If you have version <= v0.02 installed:** Remove all pre-existing files from both step#2 and step#6 and install the new version as instructed before. + +**If you have version > v0.02 installed:** Use the *Upgrade Module* button at the top of the module interface to check for and install new updates. ## Usage diff --git a/server/server.py b/server/server.py index 67e434a..c84bb22 100644 --- a/server/server.py +++ b/server/server.py @@ -222,7 +222,8 @@ def get_image(wmin: int, wmax: int): # ), f"Accept either 1 channel gray image or 3 channel rgb. Got image shape {arr.shape} " image = arr # H, W = arr.shape[1:] # TODO: make sure h, w not filpped #################### This line is causing problem - W, H = arr.shape[1:] + H, W = arr.shape[1:] + print('Line 225, H, W:', H, W, file=sys.stderr) for slice_idx in range(image.shape[0]): # for slice_idx in tqdm(range(4)): @@ -266,6 +267,7 @@ def get_bbox1024(mask_1024, bbox_shift=3): y_min, y_max = np.min(y_indices), np.max(y_indices) # add perturbation to bounding box coordinates H, W = mask_1024.shape + print('Line 269, H, W:', H, W, file=sys.stderr) x_min = max(0, x_min - bbox_shift) x_max = min(W, x_max + bbox_shift) y_min = max(0, y_min - bbox_shift) diff --git a/server_essentials.zip b/server_essentials.zip index a8698f43de7b0205352cfa3ed01dd06bd425d759..bfdec3f37e80e4142c78540a1a6ac5c396764267 100644 GIT binary patch delta 9710 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 GaQq*b3`3s) delta 9952 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