diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f7275bb --- /dev/null +++ b/.dockerignore @@ -0,0 +1 @@ +venv/ diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..3e9caf2 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,20 @@ +name: Sync to Hugging Face hub +on: + push: + branches: [ main ] + + # to run this workflow manually from the Actions tab + workflow_dispatch: + +jobs: + sync-to-hub: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + lfs: true + - name: Push to hub + env: + HF_TOKEN: ${{ secrets.HF_TOKEN }} + run: git push https://andreped:$HF_TOKEN@huggingface.co/spaces/andreped/neukit main diff --git a/.github/workflows/filesize.yml b/.github/workflows/filesize.yml new file mode 100644 index 0000000..26fb719 --- /dev/null +++ b/.github/workflows/filesize.yml @@ -0,0 +1,16 @@ +name: Check file size +on: # or directly `on: [push]` to run the action on every push on any branch + pull_request: + branches: [ main ] + + # to run this workflow manually from the Actions tab + workflow_dispatch: + +jobs: + sync-to-hub: + runs-on: ubuntu-latest + steps: + - name: Check large files + uses: ActionsDesk/lfs-warning@v2.0 + with: + filesizelimit: 10485760 # this is 10MB so we can sync to HF Spaces diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f7275bb --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +venv/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..947867d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,70 @@ +# read the doc: https://huggingface.co/docs/hub/spaces-sdks-docker +# you will also find guides on how best to write your Dockerfile + +# creates virtual ubuntu in docker image +FROM ubuntu:22.04 + +# set language, format and stuff +ENV LANG=C.UTF-8 LC_ALL=C.UTF-8 + +# NOTE: using -y is conveniently to automatically answer yes to all the questions +# installing python3 with a specific version +RUN apt-get update -y +RUN apt-get upgrade -y +RUN apt install software-properties-common -y +RUN add-apt-repository ppa:deadsnakes/ppa -y +RUN apt update +RUN apt install python3.7 -y +RUN apt install python3.7-distutils -y +RUN update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.7 1 + +# installing other libraries +RUN apt-get install python3-pip -y && \ + apt-get -y install sudo +RUN apt-get install curl -y +RUN apt-get install nano -y +RUN apt-get update && apt-get install -y git +RUN apt-get install libblas-dev -y && apt-get install liblapack-dev -y +RUN apt-get install gfortran -y +RUN apt-get install libpng-dev -y +RUN apt-get install python3-dev -y +# RUN apt-get -y install cmake curl + +WORKDIR /code + +# install dependencies +COPY ./requirements.txt /code/requirements.txt +RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt + +# resolve issue with tf==2.4 and gradio dependency collision issue +RUN pip install --force-reinstall typing_extensions==4.0.0 + +# Install wget +RUN apt install wget -y && \ + apt install unzip + +# Set up a new user named "user" with user ID 1000 +RUN useradd -m -u 1000 user + +# Switch to the "user" user +USER user + +# Set home to the user's home directory +ENV HOME=/home/user \ + PATH=/home/user/.local/bin:$PATH + +# Set the working directory to the user's home directory +WORKDIR $HOME/app + +# Copy the current directory contents into the container at $HOME/app setting the owner to the user +COPY --chown=user . $HOME/app + +# Download pretrained parenchyma model +RUN wget "https://github.com/raidionics/Raidionics-models/releases/download/1.2.0/Raidionics-MRI_Meningioma-ONNX-v12.zip" && \ + unzip "Raidionics-MRI_Meningioma-ONNX-v12.zip" && mkdir -p resources/models/ && mv MRI_Meningioma/ resources/models/MRI_Meningioma/ + +# Download test sample +RUN pip install gdown && gdown "https://drive.google.com/uc?id=1shjSrFjS4PHE5sTku30PZTLPZpGu24o3" + +# CMD ["/bin/bash"] +CMD ["python3", "demo/app.py"] diff --git a/README.md b/README.md index 3394099..382e42c 100644 --- a/README.md +++ b/README.md @@ -1 +1,28 @@ -# neurokit \ No newline at end of file +--- +title: 'neukit: automatic meningioma segmentation from T1-weighted MRI' +colorFrom: indigo +colorTo: indigo +sdk: docker +app_port: 7860 +emoji: 🔎 +pinned: false +license: mit +app_file: app.py +--- + +# neukit + +## Usage + +The software will be made openly available on Hugging Face spaces very soon. Stay tuned for more! + +## Setup + +For development of this software, follow these steps to build the docker image and run the app through it: + +``` +docker build -t neukit .. +docker run -it -p 7860:7860 neukit +``` + +Then open `http://127.0.0.1:7860` in your favourite internet browser to view the demo. diff --git a/app.py b/app.py new file mode 100644 index 0000000..9a05ab0 --- /dev/null +++ b/app.py @@ -0,0 +1,19 @@ +from neukit.gui import WebUI + + +def main(): + print("Launching demo...") + + # cwd = "/Users/andreped/workspace/livermask/" # local testing -> macOS + cwd = "/home/user/app/" # production -> docker + + model_name = "model.h5" # assumed to lie in `cwd` directory + class_name = "parenchyma" + + # initialize and run app + app = WebUI(model_name=model_name, class_name=class_name, cwd=cwd) + app.run() + + +if __name__ == "__main__": + main() diff --git a/neukit/__init__.py b/neukit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/neukit/gui.py b/neukit/gui.py new file mode 100644 index 0000000..8136e8e --- /dev/null +++ b/neukit/gui.py @@ -0,0 +1,94 @@ +import gradio as gr +from .utils import load_ct_to_numpy, load_pred_volume_to_numpy +from .compute import run_model +from .convert import nifti_to_glb + + +class WebUI: + def __init__(self, model_name:str = None, class_name:str = None, cwd:str = None): + # global states + self.images = [] + self.pred_images = [] + + # @TODO: This should be dynamically set based on chosen volume size + self.nb_slider_items = 100 + + self.model_name = model_name + self.class_name = class_name + self.cwd = cwd + + # define widgets not to be rendered immediantly, but later on + self.slider = gr.Slider(1, self.nb_slider_items, value=1, step=1, label="Which 2D slice to show") + self.volume_renderer = gr.Model3D( + clear_color=[0.0, 0.0, 0.0, 0.0], + label="3D Model", + visible=True + ).style(height=512) + + def combine_ct_and_seg(self, img, pred): + return (img, [(pred, self.class_name)]) + + def upload_file(self, file): + return file.name + + def load_mesh(self, mesh_file_name, model_name): + path = mesh_file_name.name + run_model(path, model_name) + nifti_to_glb("prediction-livermask.nii") + self.images = load_ct_to_numpy(path) + self.pred_images = load_pred_volume_to_numpy("./prediction-livermask.nii") + self.slider = self.slider.update(value=2) + return "./prediction.obj" + + def get_img_pred_pair(self, k): + k = int(k) - 1 + out = [gr.AnnotatedImage.update(visible=False)] * self.nb_slider_items + out[k] = gr.AnnotatedImage.update(self.combine_ct_and_seg(self.images[k], self.pred_images[k]), visible=True) + return out + + def run(self): + with gr.Blocks() as demo: + + with gr.Row().style(equal_height=True): + file_output = gr.File( + file_types=[".nii", ".nii.nz"], + file_count="single" + ).style(full_width=False, size="sm") + file_output.upload(self.upload_file, file_output, file_output) + + run_btn = gr.Button("Run analysis").style(full_width=False, size="sm") + run_btn.click( + fn=lambda x: self.load_mesh(x, model_name=self.cwd + self.model_name), + inputs=file_output, + outputs=self.volume_renderer + ) + + with gr.Row().style(equal_height=True): + gr.Examples( + examples=[self.cwd + "test-volume.nii"], + inputs=file_output, + outputs=file_output, + fn=self.upload_file, + cache_examples=True, + ) + + with gr.Row().style(equal_height=True): + with gr.Box(): + image_boxes = [] + for i in range(self.nb_slider_items): + visibility = True if i == 1 else False + t = gr.AnnotatedImage(visible=visibility)\ + .style(color_map={self.class_name: "#ffae00"}, height=512, width=512) + image_boxes.append(t) + + self.slider.change(self.get_img_pred_pair, self.slider, image_boxes) + + with gr.Box(): + self.volume_renderer.render() + + with gr.Row(): + self.slider.render() + + # sharing app publicly -> share=True: https://gradio.app/sharing-your-app/ + # inference times > 60 seconds -> need queue(): https://github.com/tloen/alpaca-lora/issues/60#issuecomment-1510006062 + demo.queue().launch(server_name="0.0.0.0", server_port=7860, share=True) diff --git a/neukit/utils.py b/neukit/utils.py new file mode 100644 index 0000000..e7d0edb --- /dev/null +++ b/neukit/utils.py @@ -0,0 +1,61 @@ +import numpy as np +import nibabel as nib +from nibabel.processing import resample_to_output +from skimage.measure import marching_cubes + + +def load_ct_to_numpy(data_path): + if type(data_path) != str: + data_path = data_path.name + + image = nib.load(data_path) + data = image.get_fdata() + + data = np.rot90(data, k=1, axes=(0, 1)) + + data[data < -150] = -150 + data[data > 250] = 250 + + data = data - np.amin(data) + data = data / np.amax(data) * 255 + data = data.astype("uint8") + + print(data.shape) + return [data[..., i] for i in range(data.shape[-1])] + + +def load_pred_volume_to_numpy(data_path): + if type(data_path) != str: + data_path = data_path.name + + image = nib.load(data_path) + data = image.get_fdata() + + data = np.rot90(data, k=1, axes=(0, 1)) + + data[data > 0] = 1 + data = data.astype("uint8") + + print(data.shape) + return [data[..., i] for i in range(data.shape[-1])] + + +def nifti_to_glb(path, output="prediction.obj"): + # load NIFTI into numpy array + image = nib.load(path) + resampled = resample_to_output(image, [1, 1, 1], order=1) + data = resampled.get_fdata().astype("uint8") + + # extract surface + verts, faces, normals, values = marching_cubes(data, 0) + faces += 1 + + with open(output, 'w') as thefile: + for item in verts: + thefile.write("v {0} {1} {2}\n".format(item[0],item[1],item[2])) + + for item in normals: + thefile.write("vn {0} {1} {2}\n".format(item[0],item[1],item[2])) + + for item in faces: + thefile.write("f {0}//{0} {1}//{1} {2}//{2}\n".format(item[0],item[1],item[2])) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..671508d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +raidionicsrads @ https://github.com/dbouget/raidionics_rads_lib/releases/download/v1.1.0/raidionicsrads-1.1.0-py3-none-manylinux1_x86_64.whl +onnxruntime-gpu==1.12.1 +gradio==3.32.0