Skip to content

Commit

Permalink
ChRIS pluginify
Browse files Browse the repository at this point in the history
  • Loading branch information
jennydaman committed Nov 10, 2023
1 parent ccedd9d commit 542c18c
Show file tree
Hide file tree
Showing 18 changed files with 400 additions and 375 deletions.
10 changes: 10 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
.git
.mambaenv
*.egg-info
__pycache__

.dockerignore
Dockerfile

/in
/out
122 changes: 122 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# Continuous integration testing for ChRIS Plugin.
# https://github.com/FNNDSC/python-chrisapp-template/wiki/Continuous-Integration
#
# - on push and PR: run pytest
# - on push to main: build and push container images as ":latest"
# - on push to semver tag: build and push container image with tag and
# upload plugin description to https://chrisstore.co

name: build

on:
push:
branches: [ master ]
tags:
- "v?[0-9]+.[0-9]+.[0-9]+*"

jobs:
build:
name: Build
runs-on: ubuntu-22.04

steps:
- name: Decide image tags
id: info
shell: python
run: |
import os
import itertools
def join_tag(t):
registry, repo, tag = t
return f'{registry}/{repo}:{tag}'.lower()
registries = ['docker.io', 'ghcr.io']
repos = ['${{ github.repository }}']
if '${{ github.ref_type }}' == 'branch':
tags = ['latest']
elif '${{ github.ref_type }}' == 'tag':
tag = '${{ github.ref_name }}'
version = tag[1:] if tag.startswith('v') else tag
tags = ['latest', version]
else:
tags = []
if '${{ github.ref_type }}' == 'tag':
local_tag = join_tag(('ghcr.io', '${{ github.repository }}', version))
else:
local_tag = join_tag(('localhost', '${{ github.repository }}', 'latest'))
product = itertools.product(registries, repos, tags)
tags_csv = ','.join(map(join_tag, product))
outputs = {
'tags_csv' : tags_csv,
'push' : 'true' if tags_csv else 'false',
'local_tag': local_tag
}
with open(os.environ['GITHUB_OUTPUT'], 'a') as out:
for k, v in outputs.items():
out.write(f'{k}={v}\n')
- uses: actions/checkout@v3
- uses: docker/setup-buildx-action@v3
with:
driver-opts: network=host

# Here, we want to do the docker build twice:
# The first build pushes to our local registry for testing.
# The second build pushes to Docker Hub and ghcr.io
- name: Build (local only)
uses: docker/build-push-action@v3
id: docker_build
with:
context: .
file: ./Dockerfile
tags: ${{ steps.info.outputs.local_tag }}
load: true
cache-from: type=gha

- name: Login to DockerHub
if: (github.event_name == 'push' || github.event_name == 'release') && contains(steps.info.outputs.tags_csv, 'docker.io')
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- name: Login to GitHub Container Registry
if: (github.event_name == 'push' || github.event_name == 'release') && contains(steps.info.outputs.tags_csv, 'ghcr.io')
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v3
if: (github.event_name == 'push' || github.event_name == 'release')
with:
context: .
file: ./Dockerfile
tags: ${{ steps.info.outputs.tags_csv }}
platforms: linux/amd64
push: ${{ steps.info.outputs.push }}
cache-to: type=gha,mode=max

- name: Upload ChRIS Plugin
id: upload
if: github.ref_type == 'tag'
uses: FNNDSC/upload-chris-plugin@v1
with:
dock_image: ${{ steps.info.outputs.local_tag }}
username: ${{ secrets.CHRISPROJECT_USERNAME }}
password: ${{ secrets.CHRISPROJECT_PASSWORD }}
chris_url: https://cube.chrisproject.org/api/v1/
compute_names: galena

- name: Update DockerHub description
if: steps.upload.outcome == 'success'
uses: peter-evans/dockerhub-description@v3
continue-on-error: true # it is not crucial that this works
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
short-description: ${{ steps.upload.outputs.title }}
readme-filepath: ./README.md
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
__pycache__
*.egg-info

.idea
.mambaenv
9 changes: 9 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
FROM docker.io/tensorflow/tensorflow:2.11.0

COPY requirements.txt /app/requirements.txt
RUN --mount=type=cache,target=/root/.cache,sharing=private pip install -r /app/requirements.txt

COPY . /app
RUN pip install /app && rm -rf /app

CMD ["emerald"]
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
MIT License

Copyright (c) 2019 FNNDSC / BCH
Copyright (c) 2019-2023 FNNDSC / BCH

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
103 changes: 12 additions & 91 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,23 +1,8 @@
# Automatic Brain Masking

## Table of contents

* [Description](#description)
* [Requirements](#requirements)
* [Limitations](#limitations)
* [Usage](#usage)
* [Setup](#setup)

## Description

Deep learning based project made to automatically mask fetal brains. It can take either individual NIfTI files or the contents
of a specified directory.

Currently only a U-net based model is available.

Depending on the input provided (a file or a directory), this tool will look for all .nii files inside of a folder.
It will save a new mask with the name name_mask.nii for each .nii file found on the path provided. and it
will skip those files that end with mask.nii unless specified otherwise with the --remasking flag.
Deep learning based project made to automatically mask fetal brains.

### About this model

Expand All @@ -32,87 +17,23 @@ Here are some images showcasing its performance against the previous model, wher

![image2](image2.png)


## Requirements

- Python 3
- pip

The following can be installed with the requirements.txt file:

- opencv-python-headless == 4.7.0.68
- MedPy == 0.4.0
- scikit-image == 0.19.3
- keras == 2.11.0
- tensorflow == 2.11.0
- tqdm == 4.64.1
- numpy == 1.24.1

## Usage

Its recommended that you create a virtual environment to prevent mixing dependencies. If you don't know how to create one,
check out the [setup](#setup) section.
`pl-emerald` is available as a _ChRIS_ plugin. You can either run it using the _ChRIS_ cloud GUI
or locally on the command line. To use it locally, move input NIFTI files to a directory and then run

Once you have a virtual environment with all the requirements installed, you can run this tool with the command:

```python
python individual_brain_mask.py target_file [target_file ...] [-h] [--remasking] [--no-remasking] [--post-processing] \
[--no-post-processing] [--match MATCH [MATCH ...]] [--dilation_footprint SHAPE SIZE] [--no-dilation]
```shell
apptainer run docker://ghcr.io/fnndsc/pl-emerald:latest emerald input/ output/
```
Where:

| Argument | Description |
|:---------------------------------:|----------------------------------------------------------------------------------------------------------------------------------------------------------|
| `target_file` | Input path. Required. |
| `-h` | Show help message and exit |
| `--remasking` | Indicates that images already masked should be remasked, rewritting all **_mask.nii* files found. Defaults to *false*. |
| `--no-remasking` | Indicates to skip images that end with **_mask.nii* |
| `--post-processing` | Indicates that the predicted mask should be post processed (morphological closing and dilation). Defaults to *true*. |
| `--no-post-processing` | Indicates that the predicted mask should *not* be post processed |
| `--match` | Specify if only files with certain words should be masked. Not case sensitive. |
| `--dilation_footprint SHAPE SIZE` | Specify the shape and size of the footprint used for dilation. Shapes available are **disk** and **square**. If none specified, default is **disk 2**. |
| `--no-dilation` | Masks without dilation. |

## Limitations
- Unet can currently only work with 256x256 images

## Setup

You can create a new virtual environment using the `venv` command:

```python
python -m venv /path/env_name
```

This will create a virtual environment called `env_name` in the directory `/path`.
To activate it, run:

```python
source /path/env_name/bin/activate
```

The environments name should appear at the beginnig of the shell surrounded by parentheses, like this:

```python
(env_name)$
```

For further information on how virtual envirionments work, check the [python documentation](https://docs.python.org/3/library/venv.html).

### Download the tool

Download the source code, cd into your desired location

```python
(env_name)$ git clone https://github.com/sofia-urosa/brain-masking.git
(env_name)$ cd brain-masking
```
- Unet can currently only work with 256x256 images

Install requirements from requirements.txt
## CPU v.s. GPU performance

```python
(env_name)$ pip install -r requirements.txt
```
And run the tool using the command found in [usage](#usage).
Quick notes about CPU v.s. GPU performance.
To process 5 input files takes ~17s on CPU, ~6s on GPU.
This includes the boot time of the program (loading models) however it does not include
the time it takes to pull and/or process the container image.
The TensorFlow base image is 437.67 MB in size, or 2.67 GB in size with Nvidia drivers.
Binary file removed __pycache__/datahandler.cpython-38.pyc
Binary file not shown.
12 changes: 12 additions & 0 deletions emerald/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
__version__ = '0.1.0'

DISPLAY_TITLE = r"""
_ _ _
| | | | | |
_ __ | |______ ___ _ __ ___ ___ _ __ __ _| | __| |
| '_ \| |______/ _ \ '_ ` _ \ / _ \ '__/ _` | |/ _` |
| |_) | | | __/ | | | | | __/ | | (_| | | (_| |
| .__/|_| \___|_| |_| |_|\___|_| \__,_|_|\__,_|
| |
|_|
"""
51 changes: 51 additions & 0 deletions emerald/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
#!/usr/bin/env python

from pathlib import Path
from argparse import ArgumentParser, Namespace, ArgumentDefaultsHelpFormatter

from chris_plugin import chris_plugin, PathMapper, curry_name_mapper

from emerald import DISPLAY_TITLE
from emerald.emerald import emerald
from emerald.model import Unet
from skimage.morphology import square, disk, cube


__AVAILABLE_FUNCTIONS = [square, disk, cube]
"""Functions which (might) get called by eval."""


parser = ArgumentParser(description='Fetal brain masking',
formatter_class=ArgumentDefaultsHelpFormatter)
parser.add_argument('-p', '--pattern', type=str, default='**/*.nii',
help='Input files pattern')
parser.add_argument('-s', '--output-suffix', type=str, default='_mask.nii',
help='Output file suffix')
parser.add_argument('--no-post-processing', dest='post_processing', action='store_false',
help='Predicted mask should not be post processed (morphological closing and defragmentation)')
parser.add_argument('--dilation-footprint', default='disk(2)', type=str,
help='Dilation footprint: either a Python expression or None.')


@chris_plugin(
parser=parser,
title='Fetal brain masking',
category='MRI',
min_memory_limit='2Gi', # supported units: Mi, Gi
min_cpu_limit='1000m', # millicores, e.g. "1000m" = 1 CPU core
min_gpu_limit=0 # set min_gpu_limit=1 to enable GPU
)
def main(options: Namespace, inputdir: Path, outputdir: Path):
print(DISPLAY_TITLE, flush=True)

model = Unet()
footprint = eval(options.dilation_footprint)

mapper = PathMapper.file_mapper(inputdir, outputdir,
glob=options.pattern, name_mapper=curry_name_mapper('{}_mask.nii'))
for input_file, output_file in mapper:
emerald(model, input_file, output_file, options.post_processing, footprint)


if __name__ == '__main__':
main()
Loading

0 comments on commit 542c18c

Please sign in to comment.