diff --git a/IDCBrowser/IDCHandlerModule.py b/IDCBrowser/IDCHandlerModule.py new file mode 100644 index 0000000..5990faa --- /dev/null +++ b/IDCBrowser/IDCHandlerModule.py @@ -0,0 +1,360 @@ +import os +import slicer +import urllib.parse +from slicer.ScriptedLoadableModule import * +from WebServer import WebServerLogic +from typing import Optional +from WebServerLib.BaseRequestHandler import ( + BaseRequestHandler, + BaseRequestLoggingFunction, +) + +import os +import platform +import subprocess +import time +import logging +import requests +from slicer.util import VTKObservationMixin +import qt +import slicer +import ctk +from idc_index import index +import sys +import shlex + + +class IDCRequestHandler(BaseRequestHandler): + + def __init__(self, logMessage: Optional[BaseRequestLoggingFunction] = None): + self.logMessage = logMessage + self.client = index.IDCClient() + self.index_df = self.client.index + + def canHandleRequest(self, uri: bytes, **_kwargs) -> float: + parsedURL = urllib.parse.urlparse(uri) + if parsedURL.path.startswith(b"/idc"): + return 0.5 + return 0.0 + + def handleRequest( + self, method: str, uri: bytes, requestBody: bytes, **_kwargs + ) -> tuple[bytes, bytes]: + parsedURL = urllib.parse.urlparse(uri) + splitPath = parsedURL.path.split(b"/") + + if len(splitPath) > 2: + if splitPath[2] == b"collections": + return self.handleCollections() + elif splitPath[2] == b"download" and splitPath[3] == b"seriesInstanceUID": + series_uids = splitPath[4].decode().split(",") + return self.handleDownload(series_uids) + elif splitPath[2] == b"download" and splitPath[3] == b"studyInstanceUID": + study_uids = splitPath[4].decode().split(",") + filtered_df = self.index_df[ + self.index_df["StudyInstanceUID"].isin(study_uids) + ] + series_uids_from_study_uid = filtered_df["SeriesInstanceUID"].tolist() + return self.handleDownload(series_uids_from_study_uid) + else: + return b"text/plain", b"Unhandled IDC request path" + else: + return b"text/plain", b"Invalid IDC request path" + + def handleCollections(self) -> tuple[bytes, bytes]: + try: + collections = self.client.get_collections() + responseBody = f"Available collections: {', '.join(collections)}".encode() + contentType = b"text/plain" + except Exception as e: + responseBody = f"Error fetching collections: {e}".encode() + contentType = b"text/plain" + if self.logMessage: + self.logMessage(responseBody.decode()) + return contentType, responseBody + + def handleDownload(self, uids: list[str]) -> tuple[bytes, bytes]: + destFolderPath = slicer.mrmlScene.GetCacheManager().GetRemoteCacheDirectory() + + try: + self.client.download_from_selection( + seriesInstanceUID=uids, + downloadDir=destFolderPath, + dirTemplate="%SeriesInstanceUID", + ) + indexer = ctk.ctkDICOMIndexer() + for uid in uids: + download_folder_path = os.path.join(destFolderPath, uid) + indexer.addDirectory(slicer.dicomDatabase, download_folder_path) + plugin = slicer.modules.dicomPlugins["DICOMScalarVolumePlugin"]() + dicomDatabase = slicer.dicomDatabase + fileList = dicomDatabase.filesForSeries(uid.replace("'", "")) + loadables = plugin.examine([fileList]) + if len(loadables) > 0: + volume = plugin.load(loadables[0]) + logging.debug("Loaded volume: " + volume.GetName()) + else: + raise Exception( + "Unable to load DICOM content. Please retry from DICOM Browser!" + ) + responseBody = ( + f"Downloaded and indexed UID(s): {', '.join(uids)}".encode() + ) + contentType = b"text/plain" + except Exception as e: + responseBody = f"Error downloading or indexing UID(s): {e}".encode() + contentType = b"text/plain" + if self.logMessage: + self.logMessage(responseBody.decode()) + return contentType, responseBody + + +class IDCHandlerModule(ScriptedLoadableModule): + def __init__(self, parent): + ScriptedLoadableModule.__init__(self, parent) + self.parent.title = "IDC Handler Module" + self.parent.categories = ["Examples"] + self.parent.contributors = [ + "Vamsi Thiriveedhi(ImagingDataCommons), Steve Pieper (Isomics, Inc.)" + ] + self.parent.helpText = """This module registers an IDCRequestHandler to handle IDC-related requests.""" + self.parent.acknowledgementText = """This was a project born during PW 40 when @pieper once mentioned the idea of using Slicer just the way we use zoom. +This post (https://discourse.slicer.org/t/how-to-load-nifti-file-from-web-browser-link/18664/5) showed that it was indeed possible, and the current implementation +is inspired from it, and the slicerio package, which was originally developed by @lassoan and team. +see https://github.com/ImagingDataCommons/SlicerIDCBrowser/pull/43 for more info. +""" + + slicer.app.connect("startupCompleted()", self.onStartupCompleted) + + def onStartupCompleted(self): + print("SlicerStartupCompleted emitted") + + PORT = 2042 + try: + logic.stop() + except NameError: + pass + logMessage = WebServerLogic.defaultLogMessage + requestHandlers = [IDCRequestHandler()] + logic = WebServerLogic( + port=PORT, + logMessage=logMessage, + enableSlicer=True, + enableStaticPages=False, + enableDICOM=True, + requestHandlers=requestHandlers, + ) + + logic.start() + print("IDC Request Handler has been registered and server started.") + + self.registerCustomProtocol() + + + def registerCustomProtocol(self): + if platform.system() == "Linux": + # Check if the protocol is already registered + + if os.path.exists( + os.path.expanduser("~/.local/share/applications/idcbrowser.desktop") + ): + print("IDC Browser URL protocol is already registered.") + return + # Get the current directory + + current_dir = os.path.dirname(os.path.realpath(__file__)) + python_script_path = shlex.quote( + os.path.join(current_dir, "Resources", "resolver.py") + ) + + python_dir = slicer.app.slicerHome + normalized_python_dir = os.path.normpath(python_dir) + + # Construct the path to PythonSlicer.exe in the same directory + + python_path = shlex.quote( + os.path.join(normalized_python_dir, "bin", "PythonSlicer") + ) + # Register IDC Browser URL protocol + + with open( + os.path.expanduser("~/.local/share/applications/idcbrowser.desktop"), + "w", + ) as f: + f.write( + f"""[Desktop Entry] +Name=IDC Browser +Exec={python_path} {python_script_path} %u +Type=Application +Terminal=false +MimeType=x-scheme-handler/idcbrowser; +""" + ) + # Update MIME database + + os.system("update-desktop-database ~/.local/share/applications/") + os.system("xdg-mime default idcbrowser.desktop x-scheme-handler/idcbrowser") + elif platform.system() == "Windows": + + # Get the directory of the current Python executable + + python_dir = os.path.dirname(sys.executable) + + # Construct the path to PythonSlicer.exe in the same directory + + python_path = os.path.join(python_dir, "PythonSlicer.exe") + + current_dir = os.path.dirname(os.path.realpath(__file__)) + python_script_path = os.path.join(current_dir, "Resources", "resolver.py") + + # Register IDC Browser URL protocol in Windows Registry + + import winreg as reg + + try: + reg.CreateKey(reg.HKEY_CURRENT_USER, r"Software\Classes\idcbrowser") + with reg.OpenKey( + reg.HKEY_CURRENT_USER, + r"Software\Classes\idcbrowser", + 0, + reg.KEY_WRITE, + ) as key: + reg.SetValue(key, None, reg.REG_SZ, "URL:IDC Browser Protocol") + reg.SetValueEx(key, "URL Protocol", 0, reg.REG_SZ, "") + reg.CreateKey( + reg.HKEY_CURRENT_USER, + r"Software\Classes\idcbrowser\shell\open\command", + ) + with reg.OpenKey( + reg.HKEY_CURRENT_USER, + r"Software\Classes\idcbrowser\shell\open\command", + 0, + reg.KEY_WRITE, + ) as key: + reg.SetValue( + key, + None, + reg.REG_SZ, + f'"{python_path}" "{python_script_path}" "%1"', + ) + print("IDC Browser URL protocol has been registered on Windows.") + except Exception as e: + print(f"Failed to register IDC Browser URL protocol on Windows: {e}") + elif platform.system() == "Darwin": + slicer_exec_dir = os.path.dirname(sys.executable) + parent_dir = os.path.dirname(slicer_exec_dir) + + # Now, you can construct the path to PythonSlicer + + python_path = shlex.quote(os.path.join(parent_dir, "bin", "PythonSlicer")) + + current_dir = os.path.dirname(os.path.realpath(__file__)) + python_script_path = shlex.quote( + os.path.join(current_dir, "Resources", "resolver.py") + ) + + def check_macos_slicer_protocol_registration(): + plist_path = os.path.expanduser( + "~/Applications/slicer-app.app/Contents/Info.plist" + ) + return os.path.exists(plist_path) + + if check_macos_slicer_protocol_registration(): + print("Slicer URL protocol is already registered.") + return + # Create the AppleScript + applescript_path = slicer.app.temporaryPath + "/slicer.applescript" + #applescript_path = os.path.expanduser("~/slicer.applescript") + with open(applescript_path, "w") as applescript_file: + applescript_file.write( + f""" + on open location this_URL + do shell script "{python_path} {python_script_path} " & quoted form of this_URL + end open location + """ + ) + # Compile the AppleScript into an app + + os.system(f"osacompile -o ~/Applications/slicer-app.app {applescript_path}") + + # Create or modify the plist file + + plist_content = """ + + + + + CFBundleAllowMixedLocalizations + + CFBundleDevelopmentRegion + en + CFBundleExecutable + applet + CFBundleIconFile + applet + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + slicer-app + CFBundlePackageType + APPL + CFBundleSignature + aplt + LSMinimumSystemVersionByArchitecture + + x86_64 + 10.6 + + LSRequiresCarbon + + NSAppleEventsUsageDescription + This script needs to control other applications to run. + NSAppleMusicUsageDescription + This script needs access to your music to run. + NSCalendarsUsageDescription + This script needs access to your calendars to run. + NSCameraUsageDescription + This script needs access to your camera to run. + NSContactsUsageDescription + This script needs access to your contacts to run. + NSHomeKitUsageDescription + This script needs access to your HomeKit Home to run. + NSMicrophoneUsageDescription + This script needs access to your microphone to run. + NSPhotoLibraryUsageDescription + This script needs access to your photos to run. + NSRemindersUsageDescription + This script needs access to your reminders to run. + NSSiriUsageDescription + This script needs access to Siri to run. + NSSystemAdministrationUsageDescription + This script needs access to administer this system to run. + OSAAppletShowStartupScreen + + CFBundleIdentifier + slicer.protocol.registration + CFBundleURLTypes + + + CFBundleURLName + idcbrowser + CFBundleURLSchemes + + idcbrowser + + + + + + """ + + plist_path = os.path.expanduser( + "~/Applications/slicer-app.app/Contents/Info.plist" + ) + with open(plist_path, "w") as plist_file: + plist_file.write(plist_content) + print("Slicer URL protocol registered successfully.") + else: + print( + "IDC Browser URL protocol registration is not supported on this operating system." + ) diff --git a/IDCBrowser/Resources/resolver.py b/IDCBrowser/Resources/resolver.py new file mode 100644 index 0000000..704fcc4 --- /dev/null +++ b/IDCBrowser/Resources/resolver.py @@ -0,0 +1,37 @@ +import sys +import urllib.parse +import requests +import webbrowser + +def resolve_url(url): + # Parse the URL + parsed_url = urllib.parse.urlparse(url) + + # Remove the scheme (idcbrowser://) from the URL and split the path + path_parts = parsed_url.netloc.split('/') + parsed_url.path.split('/')[1:] + + # Check the first part of the path to determine the endpoint + if path_parts[0] == 'collections': + new_url = "http://localhost:2042/idc/collections" + # Open the new URL in a web browser + webbrowser.open(new_url) + elif path_parts[0] == 'series': + new_url = f"http://localhost:2042/idc/download/seriesInstanceUID/{path_parts[1]}" + elif path_parts[0] == 'studies': + new_url = f"http://localhost:2042/idc/download/studyInstanceUID/{path_parts[1]}" + else: + print(f"Unhandled path: {path_parts[0]}") + return + + # Make the request to the new URL + response = requests.get(new_url) + + # Print the response + print(response.text) + +if __name__ == "__main__": + # The URL is passed as the first argument + url = sys.argv[1] + + # Resolve the URL + resolve_url(url)