Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: fastapi inference server implementation #73

Merged
merged 28 commits into from
Oct 22, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
a088ebb
ENH: fastapi implementation for running inference remotely
che85 Jul 5, 2024
24c200f
BUG: kill web server when Slicer is closed
che85 Sep 19, 2024
7e3d099
BUG: fix server address connection
che85 Sep 19, 2024
b85eea5
STYLE: preventing to show all logging to user and addressed other com…
che85 Oct 4, 2024
c627858
Merge remote-tracking branch 'che85/remote_server' into remote_server
che85 Oct 4, 2024
bb0574f
ENH: requirements installation and reloading submodules if requested
che85 Oct 7, 2024
9f41e26
ENH: if attempting server start from Slicer, install requirements and…
che85 Oct 7, 2024
8d2efb1
Minor tweaks
lassoan Oct 7, 2024
d3a0bc8
BUG: minor issues
che85 Oct 7, 2024
77e11da
STYLE: moved download models into ModelDatabase
che85 Oct 8, 2024
0954af9
ENH: introducing LocalInference, InferenceServer and their parent cla…
che85 Oct 10, 2024
0f6adfd
STYLE: for local inference, processInfo is required and not optional
che85 Oct 10, 2024
0de1609
BUG: server port spinbox changes not saved into parameter node
che85 Oct 14, 2024
9cc8380
BUG: fastapi server on Windows causing issues when reload set to True
che85 Oct 16, 2024
3f65b4c
ENH: added progress bar for more progress feedback
che85 Oct 17, 2024
42b3d49
ENH: improved user feedback for local inference and server process
che85 Oct 17, 2024
657da6a
BUG: clearOutputFolder shouldn't be used in case of model download
che85 Oct 17, 2024
99282ef
GUI tweaks
lassoan Oct 18, 2024
416d085
BUG: proper quoting when creating cmd line for server processes
che85 Oct 18, 2024
a7d516a
BUG: run inference process as exec vs shell
che85 Oct 18, 2024
317740e
ENH: improved error feedback on client side
che85 Oct 21, 2024
5cdb7b0
BUG: unchecking remoteServerButton whenever status of remote checkbox…
che85 Oct 21, 2024
89550c2
BUG: stylesheet of remote server button was not adapted after changin…
che85 Oct 21, 2024
35da357
ENH: limit number of inference requests to 5 requests per minute
che85 Oct 21, 2024
970fc1f
ENH: improved error display and making statusLabel readonly
che85 Oct 21, 2024
06c938f
Fix server launching on Windows
lassoan Oct 21, 2024
c789d04
BUG: display process information in GUI when running local inference
che85 Oct 22, 2024
0ba4436
Improve Remote processing button
lassoan Oct 22, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions MONAIAuto3DSeg/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@ set(MODULE_NAME MONAIAuto3DSeg)
#-----------------------------------------------------------------------------
set(MODULE_PYTHON_SCRIPTS
${MODULE_NAME}.py
${MODULE_NAME}Lib/__init__.py
${MODULE_NAME}Lib/constants.py
${MODULE_NAME}Lib/dependency_handler.py
${MODULE_NAME}Lib/log_handler.py
${MODULE_NAME}Lib/model_database.py
${MODULE_NAME}Lib/server.py
${MODULE_NAME}Lib/utils.py
auto3dseg/__init__py
auto3dseg/main.py
)

set(MODULE_PYTHON_RESOURCES
Expand Down
969 changes: 496 additions & 473 deletions MONAIAuto3DSeg/MONAIAuto3DSeg.py

Large diffs are not rendered by default.

Empty file.
1 change: 1 addition & 0 deletions MONAIAuto3DSeg/MONAIAuto3DSegLib/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
APPLICATION_NAME = "MONAIAuto3DSeg"
che85 marked this conversation as resolved.
Show resolved Hide resolved
130 changes: 130 additions & 0 deletions MONAIAuto3DSeg/MONAIAuto3DSegLib/dependency_handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import sys
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if we need this new custom Python dependency installer.

  1. There are efforts to move towards more standard way of declaring Python dependencies in Slicer Python scripted modules, while still allowing lazy installation and import. See Allow scripted modules to declare and lazily install pip requirements Slicer/Slicer#7707 and Improving Support for Python Package Dependencies in Slicer Extensions Slicer/Slicer#7171. Therefore, I would not invest time into an alternative way of installing dependencies for one specific extension, but rather spend that time with improving and switching to this new approach.

  2. We could add Slicer-specific improvements that are visible to the user. But I don't see such improvements in this class (it seems pretty much the same functionality as before with slightly different design). For example, if we spend time with improving dependency installation then we could show progress information and errors in a popup window during pip install so that the user knows that something is happening and he has to wait.

If this dependency handler was needed for the uvicorn server then it should be moved there, to make it clear that it is only for that special case.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As long as the Slicer core efforts are not integrated, I am leaning toward using the current version.


import shutil
import subprocess
import logging

from MONAIAuto3DSegLib.constants import APPLICATION_NAME
logger = logging.getLogger(APPLICATION_NAME)


from abc import ABC, abstractmethod


class DependenciesBase(ABC):

minimumTorchVersion = "1.12"

def __init__(self):
self.dependenciesInstalled = False # we don't know yet if dependencies have been installed

@abstractmethod
def installedMONAIPythonPackageInfo(self):
pass

@abstractmethod
def setupPythonRequirements(self, upgrade=False):
pass


class LocalPythonDependencies(DependenciesBase):

def installedMONAIPythonPackageInfo(self):
versionInfo = subprocess.check_output([sys.executable, "-m", "pip", "show", "MONAI"]).decode()
return versionInfo

def _checkModuleInstalled(self, moduleName):
try:
import importlib
importlib.import_module(moduleName)
return True
except ModuleNotFoundError:
return False

def setupPythonRequirements(self, upgrade=False):
def install(package):
subprocess.check_call([sys.executable, "-m", "pip", "install", package])
che85 marked this conversation as resolved.
Show resolved Hide resolved

logger.info("Initializing PyTorch...")

packageName = "torch"
if not self._checkModuleInstalled(packageName):
logger.info("PyTorch Python package is required. Installing... (it may take several minutes)")
install(packageName)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This only works on macOS. Installation command depends on operating system, hardware, and drivers - see the table here: https://pytorch.org/get-started/locally/ To automate it, you need to use a package like light-the-torch. Even with that it is not completely trivial, that's why we have the Slicer pytorch extension. Please always use that extension for installing pytorch.

Copy link
Contributor Author

@che85 che85 Oct 3, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This may have been misleading then. The dependency_handler's task is to differentiate between LocalPythonDependencies (running fastapi server locally) vs SlicerPythonDependencies (running webserver within 3D Slicer).

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is PyTorch always running in Slicer's Python environment and installed by SlicerPytorch extension?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When server is started from Slicer, SlicerPython will be used. If server is started outside of Slicer, a local environment is required.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PyTorch is installed using the SlicerPytorch extension.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, let me check and I will get back to you

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lassoan I made a few changes. When starting the server from Slicer, availability of PyTorch extension will be checked. If successful, other Python packages (monai) will be checked and installed.

As mentioned before, the server can be used independently from Slicer by just running uvicorn from the commandline.

if not self._checkModuleInstalled(packageName):
raise ValueError("pytorch needs to be installed to use this module.")
else: # torch is installed, check version
from packaging import version
import torch
if version.parse(torch.__version__) < version.parse(self.minimumTorchVersion):
raise ValueError(f"PyTorch version {torch.__version__} is not compatible with this module."
+ f" Minimum required version is {self.minimumTorchVersion}. You can use 'PyTorch Util' module to install PyTorch"
+ f" with version requirement set to: >={self.minimumTorchVersion}")

logger.info("Initializing MONAI...")
monaiInstallString = "monai[fire,pyyaml,nibabel,pynrrd,psutil,tensorboard,skimage,itk,tqdm]>=1.3"
if upgrade:
monaiInstallString += " --upgrade"
install(monaiInstallString)

self.dependenciesInstalled = True
logger.info("Dependencies are set up successfully.")


class RemotePythonDependencies(DependenciesBase):

def installedMONAIPythonPackageInfo(self, server_address):
if not server_address:
return []
else:
import json
import requests
response = requests.get(server_address + "/monaiinfo")
json_data = json.loads(response.text)
return json_data

def setupPythonRequirements(self, upgrade=False):
logger.error("No permission to update remote python packages. Please contact developer.")


class SlicerPythonDependencies(DependenciesBase):

def installedMONAIPythonPackageInfo(self):
versionInfo = subprocess.check_output([shutil.which("PythonSlicer"), "-m", "pip", "show", "MONAI"]).decode()
return versionInfo

def setupPythonRequirements(self, upgrade=False):
# Install PyTorch
try:
import PyTorchUtils
except ModuleNotFoundError as e:
raise RuntimeError("This module requires PyTorch extension. Install it from the Extensions Manager.")

logger.info("Initializing PyTorch...")

torchLogic = PyTorchUtils.PyTorchUtilsLogic()
if not torchLogic.torchInstalled():
logger.info("PyTorch Python package is required. Installing... (it may take several minutes)")
torch = torchLogic.installTorch(askConfirmation=True, torchVersionRequirement=f">={self.minimumTorchVersion}")
if torch is None:
raise ValueError("PyTorch extension needs to be installed to use this module.")
else: # torch is installed, check version
from packaging import version
if version.parse(torchLogic.torch.__version__) < version.parse(self.minimumTorchVersion):
raise ValueError(f"PyTorch version {torchLogic.torch.__version__} is not compatible with this module."
+ f" Minimum required version is {self.minimumTorchVersion}. You can use 'PyTorch Util' module to install PyTorch"
+ f" with version requirement set to: >={self.minimumTorchVersion}")

# Install MONAI with required components
logger.info("Initializing MONAI...")
# Specify minimum version 1.3, as this is a known working version (it is possible that an earlier version works, too).
# Without this, for some users monai-0.9.0 got installed, which failed with this error:
# "ImportError: cannot import name ‘MetaKeys’ from 'monai.utils'"
monaiInstallString = "monai[fire,pyyaml,nibabel,pynrrd,psutil,tensorboard,skimage,itk,tqdm]>=1.3"
if upgrade:
monaiInstallString += " --upgrade"
import slicer
slicer.util.pip_install(monaiInstallString)

self.dependenciesInstalled = True
logger.info("Dependencies are set up successfully.")
27 changes: 27 additions & 0 deletions MONAIAuto3DSeg/MONAIAuto3DSegLib/log_handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import logging
che85 marked this conversation as resolved.
Show resolved Hide resolved
from typing import Callable


class LogHandler(logging.Handler):
"""

code:

logger = logging.getLogger("XYZ")

callback = ... # any callable
# NB: only catching info level messages and forwarding it to callback
handler = LogHandler(callback, logging.INFO)
# can format log messages
formatter = logging.Formatter('%(levelname)s - %(message)s')
handler.setFormatter(formatter)
logger.addHandler(handler)

"""
def __init__(self, callback: Callable, level=logging.NOTSET):
self._callback = callback
super().__init__(level)

def emit(self, record):
msg = self.format(record)
self._callback(msg)
196 changes: 196 additions & 0 deletions MONAIAuto3DSeg/MONAIAuto3DSegLib/model_database.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import json
import logging
import os
from pathlib import Path

from MONAIAuto3DSegLib.utils import humanReadableTimeFromSec
from MONAIAuto3DSegLib.constants import APPLICATION_NAME


logger = logging.getLogger(APPLICATION_NAME)


class ModelDatabase:
che85 marked this conversation as resolved.
Show resolved Hide resolved

@property
def defaultModel(self):
return self.models[0]["id"]

@property
def models(self):
if not self._models:
self._models = self.loadModelsDescription()
return self._models

@property
def modelsPath(self):
modelsPath = self.fileCachePath.joinpath("models")
modelsPath.mkdir(exist_ok=True, parents=True)
return modelsPath

@property
def modelsDescriptionJsonFilePath(self):
return os.path.join(self.moduleDir, "Resources", "Models.json")

def __init__(self):
self.fileCachePath = Path.home().joinpath(f".{APPLICATION_NAME}")
self.moduleDir = Path(__file__).parent.parent

# Disabling this flag preserves input and output data after execution is completed,
# which can be useful for troubleshooting.
self.clearOutputFolder = True
self._models = []

def model(self, modelId):
for model in self.models:
if model["id"] == modelId:
return model
raise RuntimeError(f"Model {modelId} not found")

def loadModelsDescription(self):
modelsJsonFilePath = self.modelsDescriptionJsonFilePath
try:
models = []
import json
import re
with open(modelsJsonFilePath) as f:
modelsTree = json.load(f)["models"]
for model in modelsTree:
deprecated = False
for version in model["versions"]:
url = version["url"]
# URL format: <path>/<filename>-v<version>.zip
# Example URL: https://github.com/lassoan/SlicerMONAIAuto3DSeg/releases/download/Models/17-segments-TotalSegmentator-v1.0.3.zip
match = re.search(r"(?P<filename>[^/]+)-v(?P<version>\d+\.\d+\.\d+)", url)
if match:
filename = match.group("filename")
version = match.group("version")
else:
logger.error(f"Failed to extract model id and version from url: {url}")
if "inputs" in model:
# Contains a list of dict. One dict for each input.
# Currently, only "title" (user-displayable name) and "namePattern" of the input are specified.
# In the future, inputs could have additional properties, such as name, type, optional, ...
inputs = model["inputs"]
else:
# Inputs are not defined, use default (single input volume)
inputs = [{"title": "Input volume"}]
segmentNames = model.get('segmentNames')
if not segmentNames:
segmentNames = "N/A"
models.append({
"id": f"{filename}-v{version}",
"title": model['title'],
"version": version,
"inputs": inputs,
"imagingModality": model["imagingModality"],
"description": model["description"],
"sampleData": model.get("sampleData"),
"segmentNames": model.get("segmentNames"),
"details":
f"<p><b>Model:</b> {model['title']} (v{version})"
f"<p><b>Description:</b> {model['description']}\n"
f"<p><b>Computation time on GPU:</b> {humanReadableTimeFromSec(model.get('segmentationTimeSecGPU'))}\n"
f"<br><b>Computation time on CPU:</b> {humanReadableTimeFromSec(model.get('segmentationTimeSecCPU'))}\n"
f"<p><b>Imaging modality:</b> {model['imagingModality']}\n"
f"<p><b>Subject:</b> {model['subject']}\n"
f"<p><b>Segments:</b> {', '.join(segmentNames)}",
"url": url,
"deprecated": deprecated
})
# First version is not deprecated, all subsequent versions are deprecated
deprecated = True
return models
except Exception as e:
import traceback
traceback.print_exc()
raise RuntimeError(f"Failed to load models description from {modelsJsonFilePath}")

def updateModelsDescriptionJsonFilePathFromTestResults(self, modelsTestResultsJsonFilePath):
modelsDescriptionJsonFilePath = self.modelsDescriptionJsonFilePath

with open(modelsTestResultsJsonFilePath) as f:
modelsTestResults = json.load(f)

with open(modelsDescriptionJsonFilePath) as f:
modelsDescription = json.load(f)

for model in modelsDescription["models"]:
title = model["title"]
for modelTestResult in modelsTestResults:
if modelTestResult["title"] == title:
for fieldName in ["segmentationTimeSecGPU", "segmentationTimeSecCPU", "segmentNames"]:
fieldValue = modelTestResult.get(fieldName)
if fieldValue:
model[fieldName] = fieldValue
break

with open(modelsDescriptionJsonFilePath, 'w', newline="\n") as f:
json.dump(modelsDescription, f, indent=2)

def createModelsDir(self):
modelsDir = self.modelsPath
if not os.path.exists(modelsDir):
os.makedirs(modelsDir)

def modelPath(self, modelName):
try:
return self._modelPath(modelName)
except RuntimeError:
self.downloadModel(modelName)
return self._modelPath(modelName)

def _modelPath(self, modelName):
modelRoot = self.modelsPath.joinpath(modelName)
# find labels.csv file within the modelRoot folder and subfolders
for path in Path(modelRoot).rglob("labels.csv"):
return path.parent
raise RuntimeError(f"Model {modelName} path not found")

def deleteAllModels(self):
if self.modelsPath.exists():
import shutil
shutil.rmtree(self.modelsPath)

def downloadModel(self, modelName):
url = self.model(modelName)["url"]
import zipfile
import requests
from tempfile import TemporaryDirectory
with TemporaryDirectory() as td:
tempDir = Path(td)
modelDir = self.modelsPath.joinpath(modelName)
Path(modelDir).mkdir(exist_ok=True)
modelZipFile = tempDir.joinpath("autoseg3d_model.zip")
logger.info(f"Downloading model '{modelName}' from {url}...")
logger.debug(f"Downloading from {url} to {modelZipFile}...")
try:
with open(modelZipFile, 'wb') as f:
with requests.get(url, stream=True) as r:
r.raise_for_status()
total_size = int(r.headers.get('content-length', 0))
reporting_increment_percent = 1.0
last_reported_download_percent = -reporting_increment_percent
downloaded_size = 0
for chunk in r.iter_content(chunk_size=8192 * 16):
f.write(chunk)
downloaded_size += len(chunk)
downloaded_percent = 100.0 * downloaded_size / total_size
if downloaded_percent - last_reported_download_percent > reporting_increment_percent:
logger.info(
f"Downloading model: {downloaded_size / 1024 / 1024:.1f}MB / {total_size / 1024 / 1024:.1f}MB ({downloaded_percent:.1f}%)")
last_reported_download_percent = downloaded_percent

logger.info(f"Download finished. Extracting to {modelDir}...")
with zipfile.ZipFile(modelZipFile, 'r') as zip_f:
zip_f.extractall(modelDir)
except Exception as e:
raise e
finally:
if self.clearOutputFolder:
logger.info("Cleaning up temporary model download folder...")
if os.path.isdir(tempDir):
import shutil
shutil.rmtree(tempDir)
else:
logger.info(f"Not cleaning up temporary model download folder: {tempDir}")
Loading