diff --git a/.github/workflows/cli-test.yml b/.github/workflows/cli-test.yml index b1304153..3067253d 100644 --- a/.github/workflows/cli-test.yml +++ b/.github/workflows/cli-test.yml @@ -11,14 +11,18 @@ on: jobs: test: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ ubuntu-latest ] + python-version: [ '3.9', '3.10' ] steps: - uses: actions/checkout@v2 - - name: Set up Python 3.6 + - name: Set up Python uses: actions/setup-python@v2 with: - python-version: 3.6 + python-version: ${{ matrix.python-version }} - name: Install dependencies run: | diff --git a/.github/workflows/tests_development.yml b/.github/workflows/tests_development.yml index 49d8ffc8..d57895e2 100644 --- a/.github/workflows/tests_development.yml +++ b/.github/workflows/tests_development.yml @@ -10,7 +10,14 @@ on: jobs: test: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ ubuntu-latest ] + python-version: [ '3.9', '3.10' ] + defaults: + run: + shell: bash -l {0} # Service containers to run with `container-job` services: # Label used to access the service container @@ -30,21 +37,24 @@ jobs: --health-interval 10s --health-timeout 5s --health-retries 5 - steps: - uses: actions/checkout@v2 - - name: Set up Python 3.6 + - name: Set up Python uses: actions/setup-python@v2 with: - python-version: 3.6 + python-version: ${{ matrix.python-version }} - - name: Install dependencies + - name: Install Python packages run: | python -m pip install --upgrade pip pip install flake8 pytest pytest-cov if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Display installed pip packages + run: | + pip list + - name: Setup flake8 annotations uses: rbialon/flake8-annotations@v1 diff --git a/.github/workflows/tests_release.yml b/.github/workflows/tests_release.yml index 3c09bda4..29c4d63f 100644 --- a/.github/workflows/tests_release.yml +++ b/.github/workflows/tests_release.yml @@ -11,7 +11,11 @@ on: jobs: test: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ ubuntu-latest ] + python-version: [ '3.9', '3.10' ] # Service containers to run with `container-job` services: # Label used to access the service container @@ -35,10 +39,10 @@ jobs: steps: - uses: actions/checkout@v2 - - name: Set up Python 3.6 + - name: Set up Python uses: actions/setup-python@v2 with: - python-version: 3.6 + python-version: ${{ matrix.python-version }} - name: Install dependencies run: | diff --git a/README.md b/README.md index b29aa161..27b301ce 100644 --- a/README.md +++ b/README.md @@ -1,48 +1,78 @@

- - Logo - -

Hazen

+ Ibn Al-Haytham +

+

hazen

-Quality assurance framework for Magnetic Resonance Imaging -
-Explore the docs » -
-
-View repo -· -Report Bug -· -Request Feature + Quality assurance framework for Magnetic Resonance Imaging +
+ Explore the docs » +
+
+ View repo + · + Report Bug + · + Request Feature

+

Please STAR this repo to receive updates about new versions of hazen!

-## Overview +--- -Please 'star' this repository to receive release updates! +## Overview +hazen is a software framework for performing automated analysis of magnetic resonance imaging (MRI) Quality Assurance data. +It provides automatic quantitative analysis for the following measurements of MRI phantom data: +- Signal-to-noise ratio (SNR) +- Spatial resolution +- Slice position and width +- Uniformity +- Ghosting +- MR Relaxometry +Some example outputs from hazen: -## Usage +| hazen snr | hazen ghosting | +| ------------------ | ------------------------------- | +| ![](docs/assets/snr-example.png) | ![](docs/assets/ghosting-example.png) | --- -### Docker -To use the docker version of hazen simply run the `hazen-app` script in a terminal. Docker must be installed on the host -system for this method to work, see [docker installation instructions](https://docs.docker.com/engine/install). -For ease of use it is recommended to copy the hazen-app script to location accessible on the path such as `/usr/local/bin` -so you can run it from any location. -e.g. +## Installation + +### Prerequisites + + - Python v3.9 + - Git + - Docker + +### Install + +First, clone this repo, then follow the instructions for your operating system. To clone: ```bash +git clone git@github.com:GSTT-CSC/hazen.git +``` + +#### Docker + +We recommend using the Docker version of hazen as it is easy to get up-and-running and is linked to the most stable release. Refer to the [Docker installation instructions](https://docs.docker.com/engine/install) to install Docker on your host computer. + +For ease of use, it is recommended to copy the `hazen-app` script to a location accessible on the path such as `/usr/local/bin`. This will allow you to run hazen from any location on your computer. Then, to use Docker hazen, simply run the `hazen-app` script appended with the function you want to use (e.g.: `snr`). + +In Terminal: + +```bash +cd hazen cp ./hazen-app /usr/local/bin -./hazen-app snr tests/data/snr/Siemens/ +# run hazen +hazen-app snr tests/data/snr/Siemens/ latest: Pulling from gsttmriphysics/hazen Digest: sha256:18603e40b45f3af4bf45f07559a08a7833af92a6efe21cb7306f758e8eeab24a @@ -56,22 +86,64 @@ docker.io/gsttmriphysics/hazen:latest 'snr_subtraction_normalised_seFoV250_2meas_slice5mm_tra_repeat_PSN_noDC_2_1': 2154.69} ``` -## Releasing -The Release Manager should ensure: -- All outstanding issues for that release have been closed or transferred to future release -- All tests are passing on Github Actions -- All documentation has been updated included version numbers -- Update version number in `hazenlib/__init__.py` -- Merge the release branch into master -- Create release on Github with new version tag (tag = version number) -- RMs of other branches should update their release from the new master release as soon as possible and deal with any merge conflicts. +#### Linux & MacOS +For developers, hazen can be installed using `pip`. We highly recommend using a virtual environment. -![image](https://user-images.githubusercontent.com/19840489/143266366-06e33949-12c7-44b4-9ed7-c0a795b5d492.png) +```bash +# Install OpenSSL +brew update +brew upgrade +brew install openssl +export LDFLAGS="-L`brew --prefix openssl`/lib" +export CPPFLAGS="-I`brew --prefix openssl`/include" -- RMs: Tom Roberts, Lucrezia Cester +# Go to local hazen repo directory +cd hazen + +# Create and activate a virtual environment +python3 -m venv ./hazen-venv +source hazen-venv/bin/activate + +# Install requirements +pip install --upgrade pip setuptools wheel +pip install -r requirements.txt +# Install hazen +python setup.py install + +# Run tests to ensure everything is working +pytest tests/ +``` + +--- + +## Usage + +### Command Line + +The CLI version of hazen is designed to be pointed at single folders containing DICOM file(s). Example datasets are provided in the `tests/data/` directory. If you are not using the Docker version of hazen, replace `hazen-app` with `hazen` in the following commands. + +To perform an SNR measurement on the provided example Philips DICOMs: + +`hazen-app snr tests/data/snr/Philips` + +To perform a spatial resolution measurement on example data provided by the East Kent Trust: + +`hazen-app spatial_resolution tests/data/resolution/philips` + +To see the full list of available tools, enter: + +`hazen-app -h` + +The `--report` option provides additional information for some of the functions. For example, the user can gain additional insight into the performance of the snr function by entering: + +`hazen-app snr tests/data/snr/Philips --report` + +### Web Interface + +WIP: we are developing a web interface for hazen. --- @@ -81,12 +153,31 @@ The Release Manager should ensure: - The RM should ensure their release branch is kept up-to-date with master - PRs should be merged into the appropriate release branch for the issue(s) it is addressing -Read CONTRIBUTING.md +If you want to contribute to the development of hazen, please take a look at: `CONTRIBUTING.md`. --- ## Users -Nothing to see here. Maybe see hazen/docs. +Please [raise an Issue](https://github.com/GSTT-CSC/hazen/issues) if you have any problems installing or running hazen. + +We have used hazen with MRI data from a handful of different MRI scanners, including multiple different vendors. If your MRI data doesn't work with hazen, or the results are unexpected, please submit an Issue and we will investigate. --- + +## Releasing + +The Release Manager should ensure: +- All outstanding issues for the current release have been closed, or, transferred to future release. +- All tests are passing on Github Actions. +- All documentation has been updated with correct version numbers. +- The version number in `hazenlib/__init__.py` has been updated. +- The `release` branch has been merged into `main` branch +- A new release has been created with a new version tag (tag = version number) + +- RMs of other branches should update their release from the latest release as soon as possible and deal with any merge conflicts. + +![image](https://user-images.githubusercontent.com/19840489/143266366-06e33949-12c7-44b4-9ed7-c0a795b5d492.png) + +- RMs: Tom Roberts, Lucrezia Cester + diff --git a/contributors.txt b/contributors.txt index 060298b3..85825fba 100644 --- a/contributors.txt +++ b/contributors.txt @@ -4,4 +4,6 @@ Dika Vilic Elizabeth Gabriel Jane Ansell Neil Heraghty +Tom Roberts +Lucrezia Cester Haris Shuaib \ No newline at end of file diff --git a/docs/assets/ghosting-example.png b/docs/assets/ghosting-example.png new file mode 100644 index 00000000..38085513 Binary files /dev/null and b/docs/assets/ghosting-example.png differ diff --git a/docs/assets/ibn-al-haytham.jpeg b/docs/assets/ibn-al-haytham.jpeg new file mode 100644 index 00000000..1aaa43aa Binary files /dev/null and b/docs/assets/ibn-al-haytham.jpeg differ diff --git a/docs/assets/snr-example.png b/docs/assets/snr-example.png new file mode 100644 index 00000000..2caec7c0 Binary files /dev/null and b/docs/assets/snr-example.png differ diff --git a/environment.yml b/environment.yml new file mode 100644 index 00000000..c23555cb --- /dev/null +++ b/environment.yml @@ -0,0 +1,45 @@ +name: hazen-3.9-miniforge + +dependencies: + - pip #=21.3.1 + - scikit-image + - pip: + - SQLAlchemy_Utils==0.33.11 + - alembic==1.0.10 + - celery==4.3.1 + - numpy==1.21.0 + - Werkzeug==0.15.6 + - opencv-python==4.5.4.58 + - Flask_Migrate==2.5.1 + - pytest>=6.2 + - coverage>=6.0.2 + - Flask_Moment==0.7.0 + - flask_heroku==0.1.9 + - Flask==1.0.3 + - pydicom==1.4.1 + - WTForms==2.2.1 + - Flask_Dropzone==1.5.3 + - Flask_Mail==0.9.1 + - Flask_Login==0.4.1 + - Flask_Bootstrap4==4.0.2 + - imutils==0.5.3 + - matplotlib==3.4.3 + - Flask_SQLAlchemy==2.4.0 + - scipy==1.7.2 + - docopt==0.6.2 + - SQLAlchemy==1.3.3 + - Flask_WTF==0.14.2 + - flask_bootstrap==3.3.7.1 + - PyJWT==1.7.1 + - gunicorn==19.9.0 + - psycopg2-binary==2.8.4 + - arrow==0.13.2 + - amqp==2.4.2 + - sphinxcontrib-needs==0.4.3 + - sphinxcontrib-napoleon + - sphinx_rtd_theme + - m2r + - python-dateutil + - email_validator + - colorlog + diff --git a/hazenlib/slice_width.py b/hazenlib/slice_width.py index e35bc53c..17f40592 100644 --- a/hazenlib/slice_width.py +++ b/hazenlib/slice_width.py @@ -362,7 +362,7 @@ def get_initial_trapezoid_fit_and_coefficients(profile, slice_thickness): n_ramp = 47 n_plateau = 55 - trapezoid_centre = round(np.median(np.argwhere(profile < np.mean(profile)))).astype(int) + trapezoid_centre = int(round(np.median(np.argwhere(profile < np.mean(profile))))) n_total = len(profile) n_left_baseline = int(trapezoid_centre - round(n_plateau / 2) - n_ramp - 1) diff --git a/hazenlib/spatial_resolution.py b/hazenlib/spatial_resolution.py index 3ac9ae59..b8645db8 100644 --- a/hazenlib/spatial_resolution.py +++ b/hazenlib/spatial_resolution.py @@ -174,15 +174,25 @@ def thresh_image(img, bound=150): def find_square(img): - cnts = cv.findContours(img.copy(), cv.RETR_LIST, cv.CHAIN_APPROX_SIMPLE) + cnts = cv.findContours(img.copy(), cv.RETR_LIST, cv.CHAIN_APPROX_SIMPLE)[0] - for c in cnts[1]: + for c in cnts: perimeter = cv.arcLength(c, True) approx = cv.approxPolyDP(c, 0.1 * perimeter, True) if len(approx) == 4: # compute the bounding box of the contour and use the # bounding box to compute the aspect ratio rect = cv.minAreaRect(approx) + + # OpenCV 4.5 adjustment + # - cv.minAreaRect() output tuple order changed since v3.4 + # - swap rect[1] order & rotate rect[2] by -90 + # – convert tuple>list>tuple to do this + rectAsList = list(rect) + rectAsList[1] = (rectAsList[1][1], rectAsList[1][0]) + rectAsList[2] = rectAsList[2] - 90 + rect = tuple(rectAsList) + box = cv.boxPoints(rect) box = np.int0(box) w, h = rect[1] diff --git a/hazenlib/tools.py b/hazenlib/tools.py index 98bb34be..e085dcf8 100644 --- a/hazenlib/tools.py +++ b/hazenlib/tools.py @@ -129,6 +129,11 @@ def get_shape(self, shape): if shape == 'rectangle' or shape == 'square': (x,y), size, angle = cv.minAreaRect(contour) + # OpenCV v4.5 adjustment + # - cv.minAreaRect() output tuple order changed since v3.4 + # - swap size order & rotate angle by -90 + size = (size[1], size[0]) + angle = angle-90 return (x,y), size, angle diff --git a/requirements.txt b/requirements.txt index e333c035..675a4fd5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,10 @@ +scikit-image==0.19.0 SQLAlchemy_Utils==0.33.11 alembic==1.0.10 celery==4.3.1 -numpy==1.17.0 -Werkzeug==0.15.4 -scikit_image==0.14.2 -opencv_python==3.4.2.17 +numpy==1.21.4 +Werkzeug==0.15.6 +opencv-python==4.5.4.58 Flask_Migrate==2.5.1 pytest>=6.2 coverage>=6.0.2 @@ -18,9 +18,9 @@ Flask_Mail==0.9.1 Flask_Login==0.4.1 Flask_Bootstrap4==4.0.2 imutils==0.5.3 -matplotlib==3.1.3 +matplotlib==3.4.3 Flask_SQLAlchemy==2.4.0 -scipy==1.3.0 +scipy==1.7.2 docopt==0.6.2 SQLAlchemy==1.3.3 Flask_WTF==0.14.2 @@ -33,8 +33,8 @@ amqp==2.4.2 sphinxcontrib-needs==0.4.3 sphinxcontrib-napoleon sphinx_rtd_theme -m2r +m2r==0.2.1 +mistune<2.0.0 python-dateutil email_validator colorlog - diff --git a/tests/test_spatial_resolution.py b/tests/test_spatial_resolution.py index 77f51a6e..d75ce493 100644 --- a/tests/test_spatial_resolution.py +++ b/tests/test_spatial_resolution.py @@ -142,31 +142,31 @@ def test_calculate_mtf(self): class TestPhilipsResolution(TestSpatialResolution): RESOLUTION_DATA = pathlib.Path(TEST_DATA_DIR / 'resolution') dicom = pydicom.read_file(str(RESOLUTION_DATA / 'philips' / "IM-0004-0002.dcm")) - TEST_SQUARE = [[293, 203], [215, 218], [231, 297], [309, 282]] + TEST_SQUARE = [[293, 203], [215, 218], [230, 297], [308, 282]] CIRCLE = [[[257, 245, 199]]] TOP_CENTRE = {'x': 254, 'y': 210} - CENTRE = {'x': 301, 'y': 242} + CENTRE = {'x': 300, 'y': 242} VOID_MEAN = 12.29 - EDGE_MEAN = 150.2975 + EDGE_MEAN = 133.57 TOP_EDGE_MEAN = 205.28 - SIGNAL_MEAN = 348.5025 + SIGNAL_MEAN = 348.5525 MTF_FE = 0.4923415061063675 - MTF_PE = 0.5415756567170043 - bisecting_normal = (282, 246, 320, 238) + MTF_PE = 0.4923415061063675 + bisecting_normal = (281, 245, 319, 239) class TestEastKentResolution(TestSpatialResolution): RESOLUTION_DATA = pathlib.Path(TEST_DATA_DIR / 'resolution') dicom = pydicom.read_file(str(RESOLUTION_DATA / 'eastkent' / "256_sag.IMA")) - TEST_SQUARE = [[142, 105], [103, 113], [111, 152], [150, 144]] + TEST_SQUARE = [[142, 105], [104, 113], [112, 152], [150, 144]] CIRCLE = [[[127, 128, 96]]] - TOP_CENTRE = {'x': 122, 'y': 109} + TOP_CENTRE = {'x': 123, 'y': 109} CENTRE = {'x': 146, 'y': 124} VOID_MEAN = 10.6375 EDGE_MEAN = 530.86 - TOP_EDGE_MEAN = 660.4125 + TOP_EDGE_MEAN = 648.46 SIGNAL_MEAN = 1576.69 - MTF_FE = 0.984683012212735 + MTF_FE = 0.9924200730536655 MTF_PE = 0.9924200730536658 bisecting_normal = (137, 126, 155, 122) diff --git a/tests/test_tools.py b/tests/test_tools.py index 8553f265..b4baa05e 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -1,6 +1,7 @@ import unittest import os +import numpy as np import pydicom import hazenlib.tools as hazen_tools @@ -63,19 +64,26 @@ def test_sag_rectangle(self): arr = pydicom.read_file(self.SAG_RECTANGLE_PHANTOM_FILE).pixel_array shape_detector = hazen_tools.ShapeDetector(arr=arr) centre, size, angle = shape_detector.get_shape('rectangle') - assert (centre, size, angle) == (self.rectangle_centre, self.rectangle_size, self.rectangle_angle) + np.testing.assert_allclose(centre, self.rectangle_centre, rtol=1e-04) + np.testing.assert_allclose(size, self.rectangle_size, rtol=1e-04) + np.testing.assert_allclose(angle, self.rectangle_angle, rtol=1e-04) def test_cor_rectangle(self): arr = pydicom.read_file(self.COR_RECTANGLE_PHANTOM_FILE).pixel_array shape_detector = hazen_tools.ShapeDetector(arr=arr) centre, size, angle = shape_detector.get_shape('rectangle') - assert (centre, size, angle) == (self.cor_rectangle_centre, self.cor_rectangle_size, self.cor_rectangle_angle) + np.testing.assert_allclose(centre, self.cor_rectangle_centre, rtol=1e-04) + np.testing.assert_allclose(size, self.cor_rectangle_size, rtol=1e-04) + np.testing.assert_allclose(angle, self.cor_rectangle_angle, rtol=1e-04) def test_cor2_rectangle(self): arr = pydicom.read_file(self.COR2_RECTANGLE_PHANTOM_FILE).pixel_array shape_detector = hazen_tools.ShapeDetector(arr=arr) centre, size, angle = shape_detector.get_shape('rectangle') - assert (centre, size, angle) == (self.cor2_rectangle_centre, self.cor2_rectangle_size, self.cor2_rectangle_angle) + np.testing.assert_allclose(centre, self.cor2_rectangle_centre, rtol=1e-04) + np.testing.assert_allclose(size, self.cor2_rectangle_size, rtol=1e-04) + np.testing.assert_allclose(angle, self.cor2_rectangle_angle, rtol=1e-04) + class Test_is_Dicom_file(unittest.TestCase):