├── .flake8
├── .gitattributes
├── .github
├── dependabot.yml
└── workflows
│ ├── build_test_deploy.yml
│ ├── code-analysis.yml
│ ├── deploy.yml
│ └── issues_to_project.yml
├── .gitignore
├── CODE_OF_CONDUCT.md
├── GeoPol.xml
├── LICENSE
├── README.md
├── SECURITY.md
├── SUPPORT.md
├── THIRDPARTYNOTICES.md
├── Tests
├── TestData
│ └── HN
│ │ ├── (134).dcm
│ │ ├── (135).dcm
│ │ └── (137).dcm
└── test_api.py
├── app.py
├── azure_config.py
├── configuration_constants.py
├── configure.py
├── conftest.py
├── dockerignore
├── docs
├── BugReporting.md
├── DeployResourcesAzureStackHub.md
├── EndToEndDemo.md
├── InferencingAPIs.md
├── InferencingContainer.md
├── InferencingEngine.md
├── MoreAboutInnerEyeProject.md
├── PullRequestsGuidelines.md
├── Setup.md
├── Stack-overflowAndOtherChannels.md
├── VideoAndBlogs.md
├── WAF_setup.md
└── WAF_setup.png
├── download_model_and_run_scoring.py
├── environment.yml
├── mypy.ini
├── requirements.txt
├── source_config.py
└── submit_for_inference.py
/.flake8:
--------------------------------------------------------------------------------
1 | [flake8]
2 | ignore = E226,E302,E41,W391, E701, W291, E722, W503, E128, E126, E127, E731, E401
3 | max-line-length = 160
4 | max-complexity = 25
5 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | *.dcm filter=lfs diff=lfs merge=lfs -text
2 | *.nii.gz filter=lfs diff=lfs merge=lfs -text
3 | *.png filter=lfs diff=lfs merge=lfs -text
4 | *.zip filter=lfs diff=lfs merge=lfs -text
5 | *.gz filter=lfs diff=lfs merge=lfs -text
6 | *.msdf5 filter=lfs diff=lfs merge=lfs -text
7 | *.dll filter=lfs diff=lfs merge=lfs -text
8 | *.rar filter=lfs diff=lfs merge=lfs -text
9 | *.tgz filter=lfs diff=lfs merge=lfs -text
10 | *.lib filter=lfs diff=lfs merge=lfs -text
11 | *.nii filter=lfs diff=lfs merge=lfs -text
12 | *.lz4 filter=lfs diff=lfs merge=lfs -text
13 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | # To get started with Dependabot version updates, you'll need to specify which
2 | # package ecosystems to update and where the package manifests are located.
3 | # Please see the documentation for all configuration options:
4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
5 |
6 | version: 2
7 | updates:
8 | - package-ecosystem: "pip" # See documentation for possible values
9 | directory: "/" # Location of package manifests
10 | schedule:
11 | interval: "daily"
12 |
--------------------------------------------------------------------------------
/.github/workflows/build_test_deploy.yml:
--------------------------------------------------------------------------------
1 | name: Build, Test and Deploy
2 |
3 | on:
4 | pull_request:
5 | branches: [ main ]
6 | schedule:
7 | - cron: '0 0 * * *'
8 |
9 | jobs:
10 | linux-build-and-test:
11 | runs-on: ubuntu-20.04
12 | steps:
13 |
14 | - name: Checkout Repo
15 | uses: actions/checkout@v2
16 | with:
17 | lfs: true
18 |
19 | - name: Set Up Conda Environment
20 | uses: conda-incubator/setup-miniconda@v2
21 | with:
22 | miniconda-version: "latest"
23 | activate-environment: inference
24 | environment-file: ./environment.yml
25 |
26 | - name: flake8
27 | shell: bash -l {0}
28 | run: |
29 | conda activate inference
30 | flake8 . --count --exit-zero --statistics
31 |
32 | - name: mypy
33 | shell: bash -l {0}
34 | run: |
35 | conda activate inference
36 | find . -type f -name "*.py" | xargs mypy
37 |
38 | - name: Test with pytest
39 | shell: bash -l {0}
40 | env:
41 | CUSTOMCONNSTR_AZUREML_SERVICE_PRINCIPAL_SECRET: ${{ secrets.CUSTOMCONNSTR_AZUREML_SERVICE_PRINCIPAL_SECRET }}
42 | CUSTOMCONNSTR_API_AUTH_SECRET: ${{ secrets.CUSTOMCONNSTR_API_AUTH_SECRET }}
43 | CLUSTER: "training-nc12"
44 | WORKSPACE_NAME: "InnerEye-DeepLearning"
45 | EXPERIMENT_NAME: "api_inference"
46 | RESOURCE_GROUP: "InnerEye-DeepLearning"
47 | SUBSCRIPTION_ID: ${{ secrets.SUBSCRIPTION_ID }}
48 | APPLICATION_ID: ${{ secrets.APPLICATION_ID }}
49 | TENANT_ID: ${{ secrets.TENANT_ID }}
50 | DATASTORE_NAME: "inferencetestimagestore"
51 | IMAGE_DATA_FOLDER: "temp-image-store"
52 | run: |
53 | conda activate inference
54 | pytest --cov=./ --cov-report=html
55 |
56 | test-azure-deployment:
57 | runs-on: ubuntu-20.04
58 | steps:
59 | - name: Checkout Repo
60 | uses: actions/checkout@v2
61 | with:
62 | lfs: true
63 |
64 | - name: Azure Login
65 | uses: Azure/login@v1
66 | with:
67 | creds: '{"clientId":"${{ secrets.AZURE_CLIENT_ID }}","clientSecret":"${{ secrets.AZURE_CLIENT_SECRET }}","subscriptionId":"${{ secrets.SUBSCRIPTION_ID }}","tenantId":"${{ secrets.TENANT_ID }}"}'
68 |
69 | - name: Deploy Azure App Service
70 | uses: azure/CLI@v1
71 | with:
72 | azcliversion: 2.42.0
73 | inlineScript: |
74 | az webapp up --name innereyeinferencetest-${{ github.run_id }} --subscription "InnerEye Dev" -g InnerEye-Inference --sku S1 --location ukwest --runtime PYTHON:3.7
75 | az webapp delete --name innereyeinferencetest-${{ github.run_id }} --subscription "InnerEye Dev" -g InnerEye-Inference
76 |
--------------------------------------------------------------------------------
/.github/workflows/code-analysis.yml:
--------------------------------------------------------------------------------
1 | # For most projects, this workflow file will not need changing; you simply need
2 | # to commit it to your repository.
3 | #
4 | # You may wish to alter this file to override the set of languages analyzed,
5 | # or to provide custom queries or build logic.
6 | #
7 | # ******** NOTE ********
8 | # We have attempted to detect the languages in your repository. Please check
9 | # the `language` matrix defined below to confirm you have the correct set of
10 | # supported CodeQL languages.
11 | #
12 | name: "CodeQL"
13 |
14 | on:
15 | push:
16 | branches: [ main ]
17 | pull_request:
18 | # The branches below must be a subset of the branches above
19 | branches: [ main ]
20 | schedule:
21 | - cron: '22 7 * * 0'
22 |
23 | jobs:
24 | analyze:
25 | name: Analyze
26 | runs-on: ubuntu-latest
27 |
28 | strategy:
29 | fail-fast: false
30 | matrix:
31 | language: [ 'python' ]
32 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
33 | # Learn more:
34 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
35 |
36 | steps:
37 | - name: Checkout repository
38 | uses: actions/checkout@v2
39 |
40 | # Initializes the CodeQL tools for scanning.
41 | - name: Initialize CodeQL
42 | uses: github/codeql-action/init@v1
43 | with:
44 | languages: ${{ matrix.language }}
45 | # If you wish to specify custom queries, you can do so here or in a config file.
46 | # By default, queries listed here will override any specified in a config file.
47 | # Prefix the list here with "+" to use these queries and those in the config file.
48 | # queries: ./path/to/local/query, your-org/your-repo/queries@main
49 |
50 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
51 | # If this step fails, then you should remove it and run the build manually (see below)
52 | - name: Autobuild
53 | uses: github/codeql-action/autobuild@v1
54 |
55 | # ℹ️ Command-line programs to run using the OS shell.
56 | # 📚 https://git.io/JvXDl
57 |
58 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
59 | # and modify them (or add more) to build your code if your project
60 | # uses a compiled language
61 |
62 | #- run: |
63 | # make bootstrap
64 | # make release
65 |
66 | - name: Perform CodeQL Analysis
67 | uses: github/codeql-action/analyze@v1
68 |
--------------------------------------------------------------------------------
/.github/workflows/deploy.yml:
--------------------------------------------------------------------------------
1 | name: Deploy to Dev
2 |
3 | on:
4 | push:
5 | branches: [ main ]
6 |
7 | jobs:
8 | build-and-deploy:
9 | runs-on: ubuntu-latest
10 | steps:
11 | - uses: actions/checkout@v2
12 | with:
13 | lfs: true
14 |
15 | - name: Azure Login
16 | uses: Azure/login@v1
17 | with:
18 | creds: '{"clientId":"${{ secrets.AZURE_CLIENT_ID }}","clientSecret":"${{ secrets.AZURE_CLIENT_SECRET }}","subscriptionId":"${{ secrets.SUBSCRIPTION_ID }}","tenantId":"${{ secrets.TENANT_ID }}"}'
19 |
20 |
21 | - name: az deploy
22 | uses: azure/CLI@v1
23 | with:
24 | azcliversion: 2.42.0
25 | inlineScript: |
26 | az webapp up --sku S1 --name innereyeinferencedev --subscription "InnerEye Dev" -g InnerEye-Inference --location ukwest --runtime PYTHON:3.7
27 |
--------------------------------------------------------------------------------
/.github/workflows/issues_to_project.yml:
--------------------------------------------------------------------------------
1 | name: Add new issues to InnerEye-OSS project
2 | on:
3 | issues:
4 | types:
5 | - opened
6 | jobs:
7 | track_issue:
8 | runs-on: ubuntu-latest
9 | steps:
10 | - name: Get project data
11 | env:
12 | GITHUB_TOKEN: ${{ secrets.INNEREYE_OSS_PROJECT_ACCESS_TOKEN }}
13 | ORGANIZATION: Microsoft
14 | PROJECT_NUMBER: 320
15 | run: |
16 | gh api graphql -f query='
17 | query($org: String!, $number: Int!) {
18 | organization(login: $org){
19 | projectNext(number: $number) {
20 | id
21 | fields(first:20) {
22 | nodes {
23 | id
24 | name
25 | settings
26 | }
27 | }
28 | }
29 | }
30 | }' -f org=$ORGANIZATION -F number=$PROJECT_NUMBER > project_data.json
31 |
32 | echo 'PROJECT_ID='$(jq '.data.organization.projectNext.id' project_data.json) >> $GITHUB_ENV
33 |
34 | - name: Add issue to project
35 | env:
36 | GITHUB_TOKEN: ${{ secrets.INNEREYE_OSS_PROJECT_ACCESS_TOKEN }}
37 | ISSUE_ID: ${{ github.event.issue.node_id }}
38 | run: |
39 | item_id="$( gh api graphql -f query='
40 | mutation($project:ID!, $issue:ID!) {
41 | addProjectNextItem(input: {projectId: $project, contentId: $issue}) {
42 | projectNextItem {
43 | id
44 | }
45 | }
46 | }' -f project=$PROJECT_ID -f issue=$ISSUE_ID --jq '.data.addProjectNextItem.projectNextItem.id')"
47 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | env/
12 | build/
13 | develop-eggs/
14 | dist/
15 | downloads/
16 | eggs/
17 | .eggs/
18 | lib/
19 | lib64/
20 | parts/
21 | sdist/
22 | var/
23 | *.egg-info/
24 | .installed.cfg
25 | *.egg
26 |
27 | # PyInstaller
28 | # Usually these files are written by a python script from a template
29 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
30 | *.manifest
31 | *.spec
32 |
33 | # Installer logs
34 | pip-log.txt
35 | pip-delete-this-directory.txt
36 |
37 | # Unit test / coverage reports
38 | htmlcov/
39 | .tox/
40 | .coverage
41 | .coverage.*
42 | .cache
43 | nosetests.xml
44 | coverage.xml
45 | *,cover
46 | .hypothesis/
47 |
48 | # Translations
49 | *.mo
50 | *.pot
51 |
52 | # Django stuff:
53 | *.log
54 | local_settings.py
55 |
56 | # Flask stuff:
57 | instance/
58 | .webassets-cache
59 |
60 | # Scrapy stuff:
61 | .scrapy
62 |
63 | # Sphinx documentation
64 | docs/_build/
65 |
66 | # PyBuilder
67 | target/
68 |
69 | # IPython Notebook
70 | .ipynb_checkpoints
71 |
72 | # pyenv
73 | .python-version
74 |
75 | # celery beat schedule file
76 | celerybeat-schedule
77 |
78 | # dotenv
79 | .env
80 |
81 | # virtualenv
82 | venv/
83 | ENV/
84 | .venv/
85 |
86 | # Spyder project settings
87 | .spyderproject
88 |
89 | # Rope project settings
90 | .ropeproject
91 |
92 | # Visual Studio Code
93 | .vscode/
94 |
95 | # Azure deployment
96 | .azure
97 |
98 | /.idea/
99 |
100 | set_environment.sh
101 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Microsoft Open Source Code of Conduct
2 |
3 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/).
4 |
5 | Resources:
6 |
7 | - [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/)
8 | - [Microsoft Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/)
9 | - Contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with questions or concerns
10 |
--------------------------------------------------------------------------------
/GeoPol.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
6 |
7 | ]>
8 |
9 |
10 |
11 | &GitRepoName;
12 |
13 |
14 |
15 | .
16 |
17 |
18 |
19 |
20 | .gitignore
21 | GeoPol.xml
22 |
23 |
24 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) Microsoft Corporation.
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # This project is now archived
2 |
3 | This project is no longer under active maintenance. It is read-only, but you can still clone or fork the repo. [Check here for further info](https://docs.github.com/en/repositories/archiving-a-github-repository/archiving-repositories).
4 | Please contact innereye_info@service.microsoft.com if you run into trouble with the "Archived" state of the repo.
5 |
6 | # Introduction
7 |
8 | InnerEye-Inference is an App Service webapp in python to run inference on medical imaging models trained with the [InnerEye-DeepLearning toolkit](https://github.com/microsoft/InnerEye-Inference).
9 |
10 | You can also integrate this with DICOM using the [InnerEye-Gateway](https://github.com/microsoft/InnerEye-Gateway).
11 |
12 | ## Getting Started
13 |
14 | ### Operating System
15 |
16 | If developing or using this tool locally, we highly recommend using [Ubuntu 20.04](https://releases.ubuntu.com/20.04/) as your operating system. This is as the Azure App Service base image will be Ubuntu. By developing locally in Ubuntu you can guarantee maximum repeatibility between local and cloud behaviour.
17 |
18 | For windows users this is easily done through [Windows Subsystem for Linux](https://learn.microsoft.com/en-us/windows/wsl/install).
19 |
20 | ### Installing Conda or Miniconda
21 |
22 | Download a Conda or Miniconda [installer for your platform](https://docs.conda.io/en/latest/miniconda.html)
23 | and run it.
24 |
25 | ### Creating a Conda environment
26 |
27 | Note that in order to create the Conda environment you will need to have build tools installed on your machine. If you are running Windows, they should be already installed with Conda distribution.
28 |
29 | You can install build tools on Ubuntu (and Debian-based distributions) by running:
30 |
31 | ```shell
32 | sudo apt-get install build-essential
33 | ```
34 |
35 | If you are running CentOS/RHEL distributions, you can install the build tools by running:
36 |
37 | ```shell
38 | yum install gcc gcc-c++ kernel-devel make
39 | ```
40 |
41 | Start the `conda` prompt for your platform. In that prompt, navigate to your repository root and run:
42 |
43 | ```console
44 | conda env create --file environment.yml
45 | conda activate inference
46 | ```
47 |
48 | ### Configuration
49 |
50 | Add this script with name set_environment.sh to set your env variables. This can be executed in Linux. The code will read the file if the environment variables are not present.
51 |
52 | ```bash
53 | #!/bin/bash
54 | export CUSTOMCONNSTR_AZUREML_SERVICE_PRINCIPAL_SECRET=
55 | export CUSTOMCONNSTR_API_AUTH_SECRET=
56 | export CLUSTER=
57 | export WORKSPACE_NAME=
58 | export EXPERIMENT_NAME=
59 | export RESOURCE_GROUP=
60 | export SUBSCRIPTION_ID=
61 | export APPLICATION_ID=
62 | export TENANT_ID=
63 | export DATASTORE_NAME=
64 | export IMAGE_DATA_FOLDER=
65 | ```
66 |
67 | Run with `source set_environment.sh`
68 |
69 | ### Running flask app locally
70 |
71 | - `flask run` to test it locally
72 |
73 | ### Testing flask app locally
74 |
75 | The app can be tested locally using [`curl`](https://curl.se/).
76 |
77 | #### Ping
78 |
79 | To check that the server is running, issue this command from a local shell:
80 |
81 | ```console
82 | curl -i -H "API_AUTH_SECRET: " http://localhost:5000/v1/ping
83 | ```
84 |
85 | This should produce an output similar to:
86 |
87 | ```text
88 | HTTP/1.0 200 OK
89 | Content-Type: text/html; charset=utf-8
90 | Content-Length: 0
91 | Server: Werkzeug/1.0.1 Python/3.7.3
92 | Date: Wed, 18 Aug 2021 11:50:20 GMT
93 | ```
94 |
95 | #### Start
96 |
97 | To test DICOM image segmentation of a file, first create `Tests/TestData/HN.zip` containing a zipped set of the test DICOM files in `Tests/TestData/HN`. Then assuming there is a model `PassThroughModel:4`, issue this command:
98 |
99 | ```text
100 | curl -i \
101 | -X POST \
102 | -H "API_AUTH_SECRET: " \
103 | --data-binary @Tests/TestData/HN.zip \
104 | http://localhost:5000/v1/model/start/PassThroughModel:4
105 | ```
106 |
107 | This should produce an output similar to:
108 |
109 | ```text
110 | HTTP/1.0 201 CREATED
111 | Content-Type: text/plain
112 | Content-Length: 33
113 | Server: Werkzeug/1.0.1 Python/3.7.3
114 | Date: Wed, 18 Aug 2021 13:00:13 GMT
115 |
116 | api_inference_1629291609_fb5dfdf9
117 | ```
118 |
119 | here `api_inference_1629291609_fb5dfdf9` is the run id for the newly submitted inference job.
120 |
121 | #### Results
122 |
123 | To monitor the progress of the previously submitted inference job, issue this command:
124 |
125 | ```console
126 | curl -i \
127 | -H "API_AUTH_SECRET: " \
128 | --head \
129 | http://localhost:5000/v1/model/results/api_inference_1629291609_fb5dfdf9 \
130 | --next \
131 | -H "API_AUTH_SECRET: " \
132 | --output "HN_rt.zip" \
133 | http://localhost:5000/v1/model/results/api_inference_1629291609_fb5dfdf9
134 | ```
135 |
136 | If the run is still in progress then this should produce output similar to:
137 |
138 | ```text
139 | HTTP/1.0 202 ACCEPTED
140 | Content-Type: text/html; charset=utf-8
141 | Content-Length: 0
142 | Server: Werkzeug/1.0.1 Python/3.7.3
143 | Date: Wed, 18 Aug 2021 13:45:20 GMT
144 |
145 | % Total % Received % Xferd Average Speed Time Time Time Current
146 | Dload Upload Total Spent Left Speed
147 | 0 0 0 0 0 0 0 0 --:--:-- 0:00:01 --:--:-- 0
148 | ```
149 |
150 | If the run is complete then this should produce an output similar to:
151 |
152 | ```text
153 | HTTP/1.0 200 OK
154 | Content-Type: application/zip
155 | Content-Length: 131202
156 | Server: Werkzeug/1.0.1 Python/3.7.3
157 | Date: Wed, 18 Aug 2021 14:01:27 GMT
158 |
159 | % Total % Received % Xferd Average Speed Time Time Time Current
160 | Dload Upload Total Spent Left Speed
161 | 100 128k 100 128k 0 0 150k 0 --:--:-- --:--:-- --:--:-- 150k
162 | ```
163 |
164 | and download the inference result as a zipped DICOM-RT file to `HN_rt.zip`.
165 |
166 | ### Running flask app in Azure
167 |
168 | 1. Install Azure CLI: `curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash`
169 | 2. Login: `az login --use-device-code`
170 | 3. Deploy: `az webapp up --sku S1 --name test-python12345 --subscription -g InnerEyeInference --location --runtime PYTHON:3.7`
171 | 4. In the Azure portal go to Monitoring > Log Stream for debugging logs
172 |
173 | ### Deployment build
174 |
175 | If you would like to reproduce the automatic deployment of the service for testing purposes:
176 |
177 | - `az ad sp create-for-rbac --name "" --role contributor --scope /subscriptions//resourceGroups/InnerEyeInference --sdk-auth`
178 | - The previous command will return a json object with the content for the variable `secrets.AZURE_CREDENTIALS` .github/workflows/deploy.yml
179 |
180 | ### Deploying Behind a WAF
181 |
182 | If you would like to deploy your Azure App Service behind a Web Application Firewall (WAF) then please see [this documentation](./docs/WAF_setup.md).
183 |
184 | ## Images
185 |
186 | During inference the image data zip file is copied to the IMAGE_DATA_FOLDER in the AzureML workspace's DATASTORE_NAME datastore. At the end of inference the copied image data zip file is overwritten with a simple line of text. At present we cannot delete these. If you would like these overwritten files removed from your datastore you can [add a policy](https://docs.microsoft.com/en-us/azure/storage/blobs/storage-lifecycle-management-concepts?tabs=azure-portal) to delete items from the datastore after a period of time. We recommend 7 days.
187 |
188 | ## Changing Dependencies
189 |
190 | The Azure App Service will use the packages specified in `requirements.txt` to create the python virtual environment in which the flask app is run. The `environment.yml` is used for local environments only. Therefore if you want to change the packages your app service has access to, you must update `requirements.txt`.
191 |
192 | ## Help and Bug Reporting
193 |
194 | 1. [Guidelines for how to report bug.](./docs/BugReporting.md)
195 |
196 | ## Licensing
197 |
198 | [MIT License](LICENSE)
199 |
200 | ### *You are responsible for the performance and any necessary testing or regulatory clearances for any models generated*
201 |
202 | ## Contributing
203 |
204 | This project welcomes contributions and suggestions. Most contributions require you to agree to a
205 | Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us
206 | the rights to use your contribution. For details, visit the [Microsoft CLA site](https://cla.opensource.microsoft.com).
207 |
208 | When you submit a pull request, a CLA bot will automatically determine whether you need to provide
209 | a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions
210 | provided by the bot. You will only need to do this once across all repos using our CLA.
211 |
212 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/).
213 | For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or
214 | contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments.
215 |
216 | ## Disclaimer
217 |
218 | The InnerEye-DeepLearning toolkit, InnerEye-Gateway and InnerEye-Inference (collectively the “Research Tools”) are provided AS-IS for use by third parties for the purposes of research, experimental design and testing of machine learning models. The Research Tools are not intended or made available for clinical use as a medical device, clinical support, diagnostic tool, or other technology intended to be used in the diagnosis, cure, mitigation, treatment, or prevention of disease or other conditions. The Research Tools are not designed or intended to be a substitute for professional medical advice, diagnosis, treatment, or judgment and should not be used as such. All users are responsible for reviewing the output of the developed model to determine whether the model meets the user’s needs and for validating and evaluating the model before any clinical use. Microsoft does not warrant that the Research Tools or any materials provided in connection therewith will be sufficient for any medical purposes or meet the health or medical requirements of any person.
219 |
220 | ## Microsoft Open Source Code of Conduct
221 |
222 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/).
223 |
224 | ## Resources
225 |
226 | - [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/)
227 | - [Microsoft Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/)
228 | - Contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with questions or concerns
229 |
--------------------------------------------------------------------------------
/SECURITY.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | ## Security
4 |
5 | Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/Microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet), [Xamarin](https://github.com/xamarin), and [our GitHub organizations](https://opensource.microsoft.com/).
6 |
7 | If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](https://docs.microsoft.com/en-us/previous-versions/tn-archive/cc751383(v=technet.10)), please report it to us as described below.
8 |
9 | ## Reporting Security Issues
10 |
11 | **Please do not report security vulnerabilities through public GitHub issues.**
12 |
13 | Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://msrc.microsoft.com/create-report).
14 |
15 | If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://www.microsoft.com/en-us/msrc/pgp-key-msrc).
16 |
17 | You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://www.microsoft.com/msrc).
18 |
19 | Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue:
20 |
21 | * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.)
22 | * Full paths of source file(s) related to the manifestation of the issue
23 | * The location of the affected source code (tag/branch/commit or direct URL)
24 | * Any special configuration required to reproduce the issue
25 | * Step-by-step instructions to reproduce the issue
26 | * Proof-of-concept or exploit code (if possible)
27 | * Impact of the issue, including how an attacker might exploit the issue
28 |
29 | This information will help us triage your report more quickly.
30 |
31 | If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://microsoft.com/msrc/bounty) page for more details about our active programs.
32 |
33 | ## Preferred Languages
34 |
35 | We prefer all communications to be in English.
36 |
37 | ## Policy
38 |
39 | Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://www.microsoft.com/en-us/msrc/cvd).
40 |
41 |
--------------------------------------------------------------------------------
/SUPPORT.md:
--------------------------------------------------------------------------------
1 | # TODO: The maintainer of this repo has not yet edited this file
2 |
3 | **REPO OWNER**: Do you want Customer Service & Support (CSS) support for this product/project?
4 |
5 | - **No CSS support:** Fill out this template with information about how to file issues and get help.
6 | - **Yes CSS support:** Fill out an intake form at [aka.ms/spot](https://aka.ms/spot). CSS will work with/help you to determine next steps. More details also available at [aka.ms/onboardsupport](https://aka.ms/onboardsupport).
7 | - **Not sure?** Fill out a SPOT intake as though the answer were "Yes". CSS will help you decide.
8 |
9 | *Then remove this first heading from this SUPPORT.MD file before publishing your repo.*
10 |
11 | # Support
12 |
13 | ## How to file issues and get help
14 |
15 | This project uses GitHub Issues to track bugs and feature requests. Please search the existing
16 | issues before filing new issues to avoid duplicates. For new issues, file your bug or
17 | feature request as a new Issue.
18 |
19 | For help and questions about using this project, please **REPO MAINTAINER: INSERT INSTRUCTIONS HERE
20 | FOR HOW TO ENGAGE REPO OWNERS OR COMMUNITY FOR HELP. COULD BE A STACK OVERFLOW TAG OR OTHER
21 | CHANNEL. WHERE WILL YOU HELP PEOPLE?**.
22 |
23 | ## Microsoft Support Policy
24 |
25 | Support for this **PROJECT or PRODUCT** is limited to the resources listed above.
26 |
--------------------------------------------------------------------------------
/Tests/TestData/HN/(134).dcm:
--------------------------------------------------------------------------------
1 | version https://git-lfs.github.com/spec/v1
2 | oid sha256:68ee5e1644d68b42f809f035a329c3432936214275ef7dacccec297ca772cb41
3 | size 526070
4 |
--------------------------------------------------------------------------------
/Tests/TestData/HN/(135).dcm:
--------------------------------------------------------------------------------
1 | version https://git-lfs.github.com/spec/v1
2 | oid sha256:d37a4e7aadffead60f3dc3e7c6320a706c5d1bfccd223aa7030e7cf9b35ee472
3 | size 526066
4 |
--------------------------------------------------------------------------------
/Tests/TestData/HN/(137).dcm:
--------------------------------------------------------------------------------
1 | version https://git-lfs.github.com/spec/v1
2 | oid sha256:905c318a5192f7c0fdfa023ef290bb80e307639ba9f000a05ee4ed1adaf82f4a
3 | size 526070
4 |
--------------------------------------------------------------------------------
/Tests/test_api.py:
--------------------------------------------------------------------------------
1 | # ------------------------------------------------------------------------------------------
2 | # Copyright (c) Microsoft Corporation. All rights reserved.
3 | # Licensed under the MIT License (MIT). See LICENSE in the repo root for license information.
4 | # ------------------------------------------------------------------------------------------
5 |
6 | import random
7 | import shutil
8 | import tempfile
9 | import time
10 | import zipfile
11 | from pathlib import Path
12 | from typing import Any, Optional
13 | from unittest import mock
14 |
15 | from app import ERROR_EXTRA_DETAILS, HTTP_STATUS_CODE, app
16 | from azureml._restclient.constants import RunStatus
17 | from azureml.core import Datastore, Experiment, Model, Workspace
18 | from azureml.exceptions import WebserviceException
19 | from configure import API_AUTH_SECRET, API_AUTH_SECRET_HEADER_NAME, get_azure_config
20 | from download_model_and_run_scoring import DELETED_IMAGE_DATA_NOTIFICATION
21 | from pydicom import dcmread
22 | from submit_for_inference import (
23 | DEFAULT_RESULT_IMAGE_NAME, IMAGEDATA_FILE_NAME, SubmitForInferenceConfig, submit_for_inference
24 | )
25 | from werkzeug.test import TestResponse
26 |
27 | # Timeout, in seconds, for Azure runs, 20 minutes.
28 | TIMEOUT_IN_SECONDS = 20 * 60
29 |
30 | # The directory containing this file.
31 | THIS_DIR: Path = Path(__file__).parent.resolve()
32 | # The TestData directory.
33 | TEST_DATA_DIR: Path = THIS_DIR / "TestData"
34 | # Test reference series.
35 | TestDicomVolumeLocation: Path = TEST_DATA_DIR / "HN"
36 |
37 | PASSTHROUGH_MODEL_ID = "PassThroughModel:1729"
38 |
39 |
40 | def assert_response_error_type(response: TestResponse, status_code: HTTP_STATUS_CODE,
41 | extra_details: Optional[ERROR_EXTRA_DETAILS] = None) -> None:
42 | """
43 | Assert that response contains an error, formatted as JSON.
44 |
45 | :param response: Response to test.
46 | :param status_code: Expected status code
47 | :param extra_details: Optional extra details.
48 | """
49 | assert response.content_type == 'application/json'
50 | # assert response.data == b''
51 | assert response.status_code == status_code.value
52 | response_json = response.json
53 |
54 | # this makes mypy happy that a dictionary has actually been returned
55 | assert response_json is not None
56 |
57 | assert len(response_json['code']) > 0
58 | assert len(response_json['detail']) > 0
59 | assert response_json['status'] == status_code.value
60 | assert len(response_json['title']) > 0
61 | if extra_details is not None:
62 | assert response_json['extra_details'] == extra_details.value
63 | else:
64 | assert 'extra_details' not in response_json
65 |
66 | def test_health_check() -> None:
67 | """
68 | Test that the health check endpoint returns 200.
69 | """
70 | with app.test_client() as client:
71 | response = client.get("/")
72 | assert response.status_code == HTTP_STATUS_CODE.OK.value
73 |
74 |
75 | def test_ping_unauthorized() -> None:
76 | """
77 | Test "/v1/ping" with unauthorized GET.
78 |
79 | This should return HTTP status code 401 (unauthorized) and error content.
80 | """
81 | with app.test_client() as client:
82 | response = client.get("/v1/ping")
83 | assert_response_error_type(response, HTTP_STATUS_CODE.UNAUTHORIZED)
84 |
85 |
86 | def test_ping_forbidden() -> None:
87 | """
88 | Test "/v1/ping" with unauthenticated GET.
89 |
90 | This should return HTTP status code 403 (forbidden) and error content.
91 | """
92 | with app.test_client() as client:
93 | response = client.get("/v1/ping",
94 | headers={API_AUTH_SECRET_HEADER_NAME: 'forbidden'})
95 | assert_response_error_type(response, HTTP_STATUS_CODE.FORBIDDEN)
96 |
97 |
98 | def test_ping_authenticated() -> None:
99 | """
100 | Test "/v1/ping" with authenticated GET.
101 |
102 | This should return HTTP status code 200 (ok) and no content.
103 | """
104 | with app.test_client() as client:
105 | response = client.get("/v1/ping",
106 | headers={API_AUTH_SECRET_HEADER_NAME: API_AUTH_SECRET})
107 | assert response.content_type == 'text/html; charset=utf-8'
108 | assert response.data == b''
109 | assert response.status_code == HTTP_STATUS_CODE.OK.value
110 |
111 |
112 | def test_model_start_unauthorized() -> None:
113 | """
114 | Test "/v1/model/start/" with unauthorized POST.
115 |
116 | This should return HTTP status code 401 (unauthorized) and error content.
117 | """
118 | with app.test_client() as client:
119 | response = client.post("/v1/model/start/ValidModel:3")
120 | assert_response_error_type(response, HTTP_STATUS_CODE.UNAUTHORIZED)
121 |
122 |
123 | def test_model_start_forbidden() -> None:
124 | """
125 | Test "/v1/model/start/" with unauthenticated POST.
126 |
127 | This should return HTTP status code 403 (forbidden) and error content.
128 | """
129 | with app.test_client() as client:
130 | response = client.post("/v1/model/start/ValidModel:3",
131 | headers={API_AUTH_SECRET_HEADER_NAME: 'forbidden'})
132 | assert_response_error_type(response, HTTP_STATUS_CODE.FORBIDDEN)
133 |
134 |
135 | def test_model_start_authenticated_invalid_model_id() -> None:
136 | """
137 | Test "/v1/model/start/" with authenticated POST but invalid model.
138 |
139 | This should return HTTP status code 404 (not found) and error message content.
140 | """
141 | # Patch Model.__init__ to raise WebserviceException as if the model_id is invalid.
142 | exception_message = "ModelNotFound: This is an invalid model id"
143 | with mock.patch.object(Model, "__init__", side_effect=WebserviceException(exception_message)):
144 | with app.test_client() as client:
145 | response = client.post("/v1/model/start/InvalidModel:1594",
146 | headers={API_AUTH_SECRET_HEADER_NAME: API_AUTH_SECRET})
147 | assert_response_error_type(response, HTTP_STATUS_CODE.NOT_FOUND,
148 | ERROR_EXTRA_DETAILS.INVALID_MODEL_ID)
149 |
150 |
151 | def test_model_start_authenticated_valid_model_id() -> None:
152 | """
153 | Test "/v1/model/start/" with authenticated POST and valid model.
154 |
155 | This should return status code 201 (Created) and run id as content.
156 | """
157 | # Mock an azureml.core.Run object to have attribute id=='test_run_id'.
158 | run_mock = mock.Mock(id='test_run_id')
159 | # Patch the method Experiment.submit to prevent the AzureML experiment actually running.
160 | with mock.patch.object(Experiment, 'submit', return_value=run_mock):
161 | with app.test_client() as client:
162 | response = client.post(f"/v1/model/start/{PASSTHROUGH_MODEL_ID}",
163 | headers={API_AUTH_SECRET_HEADER_NAME: API_AUTH_SECRET})
164 | assert response.status_code == HTTP_STATUS_CODE.CREATED.value
165 | assert response.content_type == 'text/plain'
166 | assert response.data == bytes(run_mock.id, 'utf-8')
167 |
168 |
169 | def test_model_results_unauthorized() -> None:
170 | """
171 | Test "/v1/model/results/" with unauthorized GET.
172 |
173 | This should return HTTP status code 401 (unauthorized) and error content.
174 | """
175 | with app.test_client() as client:
176 | response = client.get("/v1/model/results/test_run_id")
177 | assert_response_error_type(response, HTTP_STATUS_CODE.UNAUTHORIZED)
178 |
179 |
180 | def test_model_results_forbidden() -> None:
181 | """
182 | Test "/v1/model/results/" with unauthenticated GET.
183 |
184 | This should return HTTP status code 403 (forbidden) and error content.
185 | """
186 | with app.test_client() as client:
187 | response = client.get("/v1/model/results/test_run_id",
188 | headers={API_AUTH_SECRET_HEADER_NAME: 'forbidden'})
189 | assert_response_error_type(response, HTTP_STATUS_CODE.FORBIDDEN)
190 |
191 |
192 | def test_model_results_authenticated_invalid_run_id() -> None:
193 | """
194 | Test "/v1/model/results/" with authenticated GET but invalid run_id.
195 |
196 | This should return HTTP status code 404 (not found) and error content.
197 | """
198 | # Patch the method Workspace.get_run to raise an exception as if the run_id was invalid.
199 | # exception_object = mock.Mock(response=mock.Mock(status_code=404))
200 | # with mock.patch.object(Workspace, 'get_run', side_effect=ServiceException(exception_object)):
201 | with app.test_client() as client:
202 | response = client.get("/v1/model/results/invalid_run_id",
203 | headers={API_AUTH_SECRET_HEADER_NAME: API_AUTH_SECRET})
204 | assert_response_error_type(response, HTTP_STATUS_CODE.NOT_FOUND,
205 | ERROR_EXTRA_DETAILS.INVALID_RUN_ID)
206 |
207 |
208 | def test_model_results_authenticated_valid_run_id_in_progress() -> None:
209 | """
210 | Test "/v1/model/results/" with authenticated GET, valid run_id but still in progress.
211 |
212 | This should return HTTP status code 202 (accepted) and no content.
213 | """
214 | # Mock an azureml.core.Run object to run status=='NOT_STARTED'.
215 | run_mock = mock.Mock(status=RunStatus.NOT_STARTED)
216 | # Patch the method Workspace.get_run to return the mock run object.
217 | with mock.patch.object(Workspace, 'get_run', return_value=run_mock):
218 | with app.test_client() as client:
219 | response = client.get("/v1/model/results/valid_run_id",
220 | headers={API_AUTH_SECRET_HEADER_NAME: API_AUTH_SECRET})
221 | assert response.content_type == 'text/html; charset=utf-8'
222 | assert response.data == b''
223 | assert response.status_code == HTTP_STATUS_CODE.ACCEPTED.value
224 |
225 |
226 | def test_model_results_authenticated_valid_run_id_completed() -> None:
227 | """
228 | Test "/v1/model/results/" with authenticated GET, valid run_id and completed.
229 |
230 | This should return HTTP status code 200 (accepted) and binary content.
231 | """
232 | # Get a random 1Kb
233 | random_bytes = bytes([random.randint(0, 255) for _ in range(0, 1024)])
234 |
235 | def download_file(name: str, output_file_path: str) -> None:
236 | with open(output_file_path, "wb+") as f:
237 | f.write(random_bytes)
238 |
239 | # Create a mock azure.core.Run object
240 | run_mock = mock.Mock(status=RunStatus.COMPLETED, download_file=download_file)
241 | # Patch the method Workspace.get_run to return the mock run object.
242 | with mock.patch.object(Workspace, 'get_run', return_value=run_mock):
243 | with app.test_client() as client:
244 | response = client.get("/v1/model/results/valid_run_id",
245 | headers={API_AUTH_SECRET_HEADER_NAME: API_AUTH_SECRET})
246 | assert response.content_type == 'application/zip'
247 | assert response.data == random_bytes
248 | assert response.status_code == HTTP_STATUS_CODE.OK.value
249 |
250 |
251 | def create_zipped_dicom_series() -> bytes:
252 | """
253 | Create a test zipped DICOM series.
254 |
255 | There are 3 slices of a full reference DICOM series in the folder TestDicomVolumeLocation.
256 | Create a zip file containing them, read the binary data and then clean up the zip file.
257 |
258 | :return: Binary contents of a zipped DICOM series.
259 | """
260 | with tempfile.TemporaryDirectory() as temp_dir:
261 | zipped_dicom_file = Path(temp_dir) / "temp.zip"
262 | shutil.make_archive(str(zipped_dicom_file.with_suffix('')), 'zip', str(TestDicomVolumeLocation))
263 | with open(zipped_dicom_file, 'rb') as f:
264 | return f.read()
265 |
266 |
267 | def submit_for_inference_and_wait(model_id: str, data: bytes) -> Any:
268 | """
269 | Submit a model and data to the inference service and wait until it has completed or failed.
270 |
271 | :param model_id: Model id to submit.
272 | :param data: Data to submit.
273 | :return: POST response.
274 | """
275 | with app.test_client() as client:
276 | response = client.post(f"/v1/model/start/{model_id}",
277 | data=data,
278 | headers={API_AUTH_SECRET_HEADER_NAME: API_AUTH_SECRET})
279 | assert response.status_code == HTTP_STATUS_CODE.CREATED.value
280 | assert response.content_type == 'text/plain'
281 | run_id = response.data.decode('utf-8')
282 | assert run_id is not None
283 |
284 | start = time.time()
285 | while True:
286 | response = client.get(f"/v1/model/results/{run_id}",
287 | headers={API_AUTH_SECRET_HEADER_NAME: API_AUTH_SECRET})
288 | if response.status_code != HTTP_STATUS_CODE.ACCEPTED.value:
289 | return response
290 |
291 | assert response.content_type == 'text/html; charset=utf-8'
292 | assert response.data == b''
293 | end = time.time()
294 | assert end - start < TIMEOUT_IN_SECONDS
295 | time.sleep(1)
296 |
297 |
298 | def test_submit_for_inference_end_to_end() -> None:
299 | """
300 | Test that submitting a zipped DICOM series to model PASSTHROUGH_MODEL_ID returns
301 | the expected DICOM-RT format.
302 | """
303 | image_data = create_zipped_dicom_series()
304 | assert len(image_data) > 0
305 | response = submit_for_inference_and_wait(PASSTHROUGH_MODEL_ID, image_data)
306 | assert response.content_type == 'application/zip'
307 | assert response.status_code == HTTP_STATUS_CODE.OK.value
308 | # Create a scratch directory
309 | with tempfile.TemporaryDirectory() as temp_dir:
310 | temp_dir_path = Path(temp_dir)
311 | # Store the response data in a file.
312 | response_file_name = temp_dir_path / "response.zip"
313 | response_file_name.write_bytes(response.data)
314 | # Check that the response data can be unzipped.
315 | extraction_folder_path = temp_dir_path / "unpack"
316 | with zipfile.ZipFile(response_file_name, 'r') as zip_file:
317 | zip_file.extractall(extraction_folder_path)
318 | # Check that there is a single file in the zip, not in a directory.
319 | extracted_files = list(extraction_folder_path.glob('**/*'))
320 | print(extracted_files)
321 | assert len(extracted_files) == 1
322 | extracted_file = extracted_files[0]
323 | assert extracted_file.is_file()
324 | relative_path = extracted_file.relative_to(extraction_folder_path)
325 | # Strip off the final .zip suffix
326 | assert relative_path == Path(DEFAULT_RESULT_IMAGE_NAME).with_suffix("")
327 |
328 | with open(extracted_file, 'rb') as infile:
329 | ds = dcmread(infile)
330 | assert ds is not None
331 | # Check the modality
332 | assert ds.Modality == 'RTSTRUCT'
333 | assert ds.Manufacturer == 'Default_Manufacturer'
334 | assert ds.SoftwareVersions == PASSTHROUGH_MODEL_ID
335 | # Check the structure names
336 | expected_structure_names = ["SpinalCord", "Lung_R", "Lung_L", "Heart", "Esophagus"]
337 | assert len(ds.StructureSetROISequence) == len(expected_structure_names)
338 | for i, item in enumerate(expected_structure_names):
339 | assert ds.StructureSetROISequence[i].ROINumber == i + 1
340 | assert ds.StructureSetROISequence[i].ROIName == item
341 | assert ds.RTROIObservationsSequence[i].RTROIInterpretedType == "ORGAN"
342 | assert "Default_Interpreter" in ds.RTROIObservationsSequence[i].ROIInterpreter
343 | assert len(ds.ROIContourSequence) == len(expected_structure_names)
344 | for i, item in enumerate(expected_structure_names):
345 | assert ds.ROIContourSequence[i].ReferencedROINumber == i + 1
346 | # Download image data zip, which should now have been overwritten
347 |
348 |
349 | def test_submit_for_inference_bad_image_file() -> None:
350 | """
351 | Test submitting a random file instead of a zipped DICOM series.
352 |
353 | This should fail because the input file is not a zip file.
354 | """
355 | # Get a random 1Kb
356 | image_data = bytes([random.randint(0, 255) for _ in range(0, 1024)])
357 | response = submit_for_inference_and_wait(PASSTHROUGH_MODEL_ID, image_data)
358 | assert_response_error_type(response, HTTP_STATUS_CODE.BAD_REQUEST,
359 | ERROR_EXTRA_DETAILS.INVALID_ZIP_FILE)
360 |
361 |
362 | def test_submit_for_inference_image_data_deletion() -> None:
363 | """
364 | Test that the image data zip is overwritten after the inference runs
365 | """
366 | image_data = create_zipped_dicom_series()
367 | azure_config = get_azure_config()
368 | workspace = azure_config.get_workspace()
369 | config = SubmitForInferenceConfig(
370 | model_id=PASSTHROUGH_MODEL_ID,
371 | image_data=image_data,
372 | experiment_name=azure_config.experiment_name)
373 | run_id, datastore_image_path = submit_for_inference(config, workspace, azure_config)
374 | run = workspace.get_run(run_id)
375 | run.wait_for_completion()
376 | image_datastore = Datastore(workspace, azure_config.datastore_name)
377 | with tempfile.TemporaryDirectory() as temp_dir:
378 | image_datastore.download(
379 | target_path=temp_dir,
380 | prefix=datastore_image_path,
381 | overwrite=False,
382 | show_progress=False)
383 | temp_dir_path = Path(temp_dir)
384 | image_data_zip_path = temp_dir_path / datastore_image_path / IMAGEDATA_FILE_NAME
385 | with image_data_zip_path.open() as image_data_file:
386 | first_line = image_data_file.readline().strip()
387 | assert first_line == DELETED_IMAGE_DATA_NOTIFICATION
388 |
--------------------------------------------------------------------------------
/app.py:
--------------------------------------------------------------------------------
1 | # ------------------------------------------------------------------------------------------
2 | # Copyright (c) Microsoft Corporation. All rights reserved.
3 | # Licensed under the MIT License (MIT). See LICENSE in the repo root for license information.
4 | # ------------------------------------------------------------------------------------------
5 |
6 | import logging
7 | import sys
8 | from enum import Enum
9 | from typing import Any, Dict, Optional
10 |
11 | from azureml._restclient.constants import RunStatus
12 | from azureml._restclient.exceptions import ServiceException
13 | from azureml.core import Run, Workspace
14 | from azureml.exceptions import WebserviceException
15 | from flask import Flask, Request, Response, jsonify, make_response, request
16 | from flask_injector import FlaskInjector
17 | from health_azure.utils import get_driver_log_file_text
18 | from injector import inject
19 | from memory_tempfile import MemoryTempfile
20 |
21 | from azure_config import AzureConfig
22 | from configure import API_AUTH_SECRET, API_AUTH_SECRET_HEADER_NAME, configure
23 | from submit_for_inference import DEFAULT_RESULT_IMAGE_NAME, SubmitForInferenceConfig, submit_for_inference
24 |
25 | app = Flask(__name__)
26 |
27 | RUNNING_OR_POST_PROCESSING = RunStatus.get_running_statuses() + RunStatus.get_post_processing_statuses()
28 |
29 | root = logging.getLogger()
30 | root.setLevel(logging.DEBUG)
31 |
32 | handler = logging.StreamHandler(sys.stdout)
33 | handler.setLevel(logging.DEBUG)
34 | formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
35 | handler.setFormatter(formatter)
36 | root.addHandler(handler)
37 |
38 |
39 | # HTTP REST status codes.
40 | class HTTP_STATUS_CODE(Enum):
41 | OK = 200
42 | CREATED = 201
43 | ACCEPTED = 202
44 | BAD_REQUEST = 400
45 | UNAUTHORIZED = 401
46 | FORBIDDEN = 403
47 | NOT_FOUND = 404
48 | INTERNAL_SERVER_ERROR = 500
49 |
50 |
51 | # HTTP REST error messages, to be formatted as JSON.
52 | ERROR_MESSAGES: Dict[HTTP_STATUS_CODE, Any] = {
53 | HTTP_STATUS_CODE.BAD_REQUEST: {
54 | 'detail': 'Input file is not in correct format.',
55 | 'title': 'InvalidInput'
56 | },
57 | HTTP_STATUS_CODE.UNAUTHORIZED: {
58 | 'detail': 'Server failed to authenticate the request. '
59 | f'Make sure the value of the {API_AUTH_SECRET_HEADER_NAME} header is populated.',
60 | 'title': 'NoAuthenticationInformation'
61 | },
62 | HTTP_STATUS_CODE.FORBIDDEN: {
63 | 'detail': 'Server failed to authenticate the request. '
64 | f'Make sure the value of the {API_AUTH_SECRET_HEADER_NAME} header is correct.',
65 | 'title': 'AuthenticationFailed'
66 | },
67 | HTTP_STATUS_CODE.NOT_FOUND: {
68 | 'detail': 'The specified resource does not exist.',
69 | 'title': 'ResourceNotFound'
70 | },
71 | HTTP_STATUS_CODE.INTERNAL_SERVER_ERROR: {
72 | 'detail': 'The server encountered an internal error. Please retry the request.',
73 | 'title': 'InternalError'
74 | },
75 | }
76 |
77 |
78 | class ERROR_EXTRA_DETAILS(Enum):
79 | INVALID_MODEL_ID = 'InvalidModelId'
80 | INVALID_ZIP_FILE = 'InvalidZipFile'
81 | RUN_CANCELLED = 'RunCancelled'
82 | INVALID_RUN_ID = 'InvalidRunId'
83 |
84 |
85 | def make_error_response(error_code: HTTP_STATUS_CODE, extra_details: Optional[ERROR_EXTRA_DETAILS] = None) -> Response:
86 | """
87 | Format a Response object for an error_code.
88 |
89 | :param error_code: Error code.
90 | :param extra_details: Optional, any further information.
91 | :return: Flask Response object with JSON error message.
92 | """
93 | error_message = ERROR_MESSAGES[error_code]
94 | error_message['code'] = error_code.name
95 | error_message['status'] = error_code.value
96 | if extra_details is not None:
97 | error_message['extra_details'] = extra_details.value
98 | return make_response(jsonify(error_message), error_code.value)
99 |
100 |
101 | def is_authenticated_request(req: Request) -> Optional[Response]:
102 | """
103 | Check request is authenticated.
104 | If API_AUTH_SECRET_HEADER_NAME is not in request headers then return 401.
105 | If API_AUTH_SECRET_HEADER_NAME is in request headers but incorrect then return 403.
106 | Else return none.
107 | :param req: Flask request object.
108 | :return: Response if error else None.
109 | """
110 | if API_AUTH_SECRET_HEADER_NAME not in req.headers:
111 | return make_error_response(HTTP_STATUS_CODE.UNAUTHORIZED)
112 | if req.headers[API_AUTH_SECRET_HEADER_NAME] != API_AUTH_SECRET:
113 | return make_error_response(HTTP_STATUS_CODE.FORBIDDEN)
114 | return None
115 |
116 |
117 | @inject
118 | @app.route("/", methods=['GET'])
119 | def health_check() -> Response:
120 | """
121 | Health check endpoint.
122 | :return: 200 OK.
123 | """
124 | return make_response(jsonify({'status': 'Healthy'}), HTTP_STATUS_CODE.OK.value)
125 |
126 |
127 | @app.route("/v1/ping", methods=['GET'])
128 | def ping() -> Response:
129 | authentication_response = is_authenticated_request(request)
130 | if authentication_response is not None:
131 | return authentication_response
132 | return make_response("", HTTP_STATUS_CODE.OK.value)
133 |
134 |
135 | @inject
136 | @app.route("/v1/model/start/", methods=['POST'])
137 | def start_model(model_id: str, workspace: Workspace, azure_config: AzureConfig) -> Response:
138 | authentication_response = is_authenticated_request(request)
139 | if authentication_response is not None:
140 | return authentication_response
141 |
142 | try:
143 | image_data: bytes = request.stream.read()
144 | logging.info(f'Starting {model_id}')
145 | config = SubmitForInferenceConfig(model_id=model_id, image_data=image_data, experiment_name=azure_config.experiment_name)
146 | run_id, _ = submit_for_inference(config, workspace, azure_config)
147 | response = make_response(run_id, HTTP_STATUS_CODE.CREATED.value)
148 | response.headers.set('Content-Type', 'text/plain')
149 | return response
150 | except WebserviceException as webException:
151 | if webException.message.startswith('ModelNotFound'):
152 | return make_error_response(HTTP_STATUS_CODE.NOT_FOUND,
153 | ERROR_EXTRA_DETAILS.INVALID_MODEL_ID)
154 | logging.error(webException)
155 | return make_error_response(HTTP_STATUS_CODE.INTERNAL_SERVER_ERROR)
156 | except Exception as fatal_error:
157 | logging.error(fatal_error)
158 | return make_error_response(HTTP_STATUS_CODE.INTERNAL_SERVER_ERROR)
159 |
160 |
161 | def check_run_logs_for_zip_errors(run: Run) -> bool:
162 | """Checks AzureML log files for zip errors.
163 |
164 | :param run: object representing run to be checked.
165 | :return: ``True`` if zip error found in logs, ``False`` if not.
166 | """
167 |
168 | driver_log = get_driver_log_file_text(run=run)
169 | return "zipfile.BadZipFile" in driver_log
170 |
171 | def get_cancelled_or_failed_run_response(run: Run, run_status: Any) -> Response:
172 | """Generates an HTTP response based upon run status
173 |
174 | :param run: Object representing run to be checked.
175 | :param run_status: Status of incomplete run.
176 | :return: HTTP response containing relevant information about run.
177 | """
178 | if run_status == RunStatus.FAILED:
179 | if check_run_logs_for_zip_errors(run):
180 | return make_error_response(HTTP_STATUS_CODE.BAD_REQUEST,
181 | ERROR_EXTRA_DETAILS.INVALID_ZIP_FILE)
182 |
183 | elif run_status == RunStatus.CANCELED:
184 | return make_error_response(HTTP_STATUS_CODE.INTERNAL_SERVER_ERROR,
185 | ERROR_EXTRA_DETAILS.RUN_CANCELLED)
186 | return make_error_response(HTTP_STATUS_CODE.INTERNAL_SERVER_ERROR)
187 |
188 |
189 | def get_completed_result_bytes(run: Run) -> Response:
190 | """Given a completed run, download the run result file and return as an HTTP response.
191 |
192 | :param run: Object representing completed run.
193 | :return: HTTP response containing result bytes.
194 | """
195 | memory_tempfile = MemoryTempfile(fallback=True)
196 | with memory_tempfile.NamedTemporaryFile() as tf:
197 | file_name = tf.name
198 | run.download_file(DEFAULT_RESULT_IMAGE_NAME, file_name)
199 | tf.seek(0)
200 | result_bytes = tf.read()
201 | response = make_response(result_bytes, HTTP_STATUS_CODE.OK.value)
202 | response.headers.set('Content-Type', 'application/zip')
203 | return response
204 |
205 |
206 | @inject
207 | @app.route("/v1/model/results/", methods=['GET'])
208 | def download_result(run_id: str, workspace: Workspace) -> Response:
209 | authentication_response = is_authenticated_request(request)
210 | if authentication_response is not None:
211 | return authentication_response
212 |
213 | logging.info(f"Checking run_id='{run_id}'")
214 | try:
215 | run = workspace.get_run(run_id)
216 | run_status = run.status
217 | if run_status in RUNNING_OR_POST_PROCESSING:
218 | return make_response("", HTTP_STATUS_CODE.ACCEPTED.value)
219 | logging.info(f"Run has completed with status {run.get_status()}")
220 |
221 | if run_status != RunStatus.COMPLETED:
222 | return get_cancelled_or_failed_run_response(run, run_status)
223 |
224 | return get_completed_result_bytes(run)
225 |
226 | except ServiceException as error:
227 | if error.status_code == 404:
228 | return make_error_response(HTTP_STATUS_CODE.NOT_FOUND,
229 | ERROR_EXTRA_DETAILS.INVALID_RUN_ID)
230 | logging.error(error)
231 | return make_error_response(HTTP_STATUS_CODE.INTERNAL_SERVER_ERROR)
232 | except Exception as fatal_error:
233 | logging.error(fatal_error)
234 | return make_error_response(HTTP_STATUS_CODE.INTERNAL_SERVER_ERROR)
235 |
236 |
237 | # Setup Flask Injector, this has to happen *AFTER* routes are added
238 | FlaskInjector(app=app, modules=[configure])
239 |
--------------------------------------------------------------------------------
/azure_config.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from typing import Optional, Union
3 |
4 | from attr import dataclass
5 | from azureml.core import Workspace, Run
6 | from azureml.core.authentication import ServicePrincipalAuthentication, InteractiveLoginAuthentication
7 |
8 |
9 | @dataclass
10 | class AzureConfig:
11 | """
12 | Azure related configurations to set up valid workspace. Note that for a parameter to be settable (when not given
13 | on the command line) to a value from settings.yml, its default here needs to be None and not the empty
14 | string, and its type will be Optional[str], not str.
15 | """
16 | subscription_id: str # "The ID of your Azure subscription."
17 | tenant_id: str # The Azure tenant ID.
18 | application_id: str # The ID of the Service Principal for authentication to Azure.
19 | workspace_name: str # The name of the AzureML workspace that should be used.
20 | resource_group: str # The Azure resource group that contains the AzureML workspace.
21 | cluster: str # The name of the GPU cluster inside the AzureML workspace, that should execute the job.
22 | experiment_name: str
23 | service_principal_secret: str
24 | datastore_name: str # The datastore data store for temp image storage.
25 | image_data_folder: str # The folder name in the data store for temp image storage.
26 | _workspace: Optional[Workspace] = None # "The cached workspace object
27 |
28 | @staticmethod
29 | def is_offline_run_context(run_context: Run) -> bool:
30 | """
31 | Tells if a run_context is offline by checking if it has an experiment associated with it.
32 | :param run_context: Context of the run to check
33 | :return:
34 | """
35 | return not hasattr(run_context, 'experiment')
36 |
37 | def get_workspace(self) -> Workspace:
38 | """
39 | Return a workspace object for an existing Azure Machine Learning Workspace (or default from YAML).
40 | When running inside AzureML, the workspace that is retrieved is always the one in the current
41 | run context. When running outside AzureML, it is created or accessed with the service principal.
42 | This function will read the workspace only in the first call to this method, subsequent calls will return
43 | a cached value.
44 | Throws an exception if the workspace doesn't exist or the required fields don't lead to a uniquely
45 | identifiable workspace.
46 | :return: Azure Machine Learning Workspace
47 | """
48 | if self._workspace:
49 | return self._workspace
50 | run_context = Run.get_context()
51 | if self.is_offline_run_context(run_context):
52 | print(self.subscription_id)
53 | print(self.resource_group)
54 | if self.subscription_id and self.resource_group:
55 | service_principal_auth = self.get_service_principal_auth()
56 | self._workspace = Workspace.get(
57 | name=self.workspace_name,
58 | auth=service_principal_auth,
59 | subscription_id=self.subscription_id,
60 | resource_group=self.resource_group)
61 | else:
62 | raise ValueError("The values for 'subscription_id' and 'resource_group' were not found. "
63 | "Was the Azure setup completed?")
64 | else:
65 | self._workspace = run_context.experiment.workspace
66 | return self._workspace
67 |
68 | def get_service_principal_auth(self) -> Optional[Union[InteractiveLoginAuthentication,
69 | ServicePrincipalAuthentication]]:
70 | """
71 | Creates a service principal authentication object with the application ID stored in the present object.
72 | The application key is read from the environment.
73 | :return: A ServicePrincipalAuthentication object that has the application ID and key or None if the key
74 | is not present
75 | """
76 | secret = self.service_principal_secret
77 | if secret is not None:
78 | logging.info("Starting with ServicePrincipalAuthentication")
79 | service_principal = ServicePrincipalAuthentication(
80 | tenant_id=self.tenant_id,
81 | service_principal_id=self.application_id,
82 | service_principal_password=secret)
83 | return service_principal
84 |
85 | raise ValueError("Invalid service_principal_secret")
86 |
--------------------------------------------------------------------------------
/configuration_constants.py:
--------------------------------------------------------------------------------
1 | # Environment variables expected for the app to run
2 | # Custom connection strings or secrets
3 | AZUREML_SERVICE_PRINCIPAL_SECRET_ENVIRONMENT_VARIABLE = "CUSTOMCONNSTR_AZUREML_SERVICE_PRINCIPAL_SECRET"
4 | API_AUTH_SECRET_ENVIRONMENT_VARIABLE = "CUSTOMCONNSTR_API_AUTH_SECRET"
5 |
6 | # Configurations
7 | CLUSTER = "CLUSTER"
8 | WORKSPACE_NAME = "WORKSPACE_NAME"
9 | EXPERIMENT_NAME = "EXPERIMENT_NAME"
10 | RESOURCE_GROUP = "RESOURCE_GROUP"
11 | SUBSCRIPTION_ID = "SUBSCRIPTION_ID"
12 | APPLICATION_ID = "APPLICATION_ID"
13 | TENANT_ID = "TENANT_ID"
14 | DATASTORE_NAME = "DATASTORE_NAME"
15 | IMAGE_DATA_FOLDER = "IMAGE_DATA_FOLDER"
16 |
--------------------------------------------------------------------------------
/configure.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import os
3 | from pathlib import Path
4 | from typing import Dict
5 |
6 | from azureml.core import Workspace
7 | from injector import singleton, Binder
8 |
9 | from azure_config import AzureConfig
10 | from configuration_constants import (API_AUTH_SECRET_ENVIRONMENT_VARIABLE, CLUSTER, WORKSPACE_NAME,
11 | EXPERIMENT_NAME, RESOURCE_GROUP, SUBSCRIPTION_ID,
12 | APPLICATION_ID, TENANT_ID, IMAGE_DATA_FOLDER, DATASTORE_NAME,
13 | AZUREML_SERVICE_PRINCIPAL_SECRET_ENVIRONMENT_VARIABLE)
14 |
15 | PROJECT_SECRETS_FILE = Path(__file__).resolve().parent / Path("set_environment.sh")
16 |
17 |
18 | def read_secret_from_file(secret_name: str) -> str:
19 | """
20 | Reads a bash file with exports and returns the variables and values as dict
21 | :return: A dictionary with secrets, or None if the file does not exist.
22 | """
23 | try:
24 | secrets_file = PROJECT_SECRETS_FILE
25 | d: Dict[str, str] = {}
26 | for line in secrets_file.read_text().splitlines():
27 | if line.startswith("#"): continue
28 | parts = line.replace("export", "").strip().split("=", 1)
29 | key = parts[0].strip().upper()
30 | d[key] = parts[1].strip()
31 | return d[secret_name]
32 | except Exception as ex:
33 | logging.error(f"Missing configuration '{secret_name}'")
34 | raise ex
35 |
36 |
37 | def get_environment_variable(environment_variable_name: str) -> str:
38 | value = os.environ.get(environment_variable_name, None)
39 | if value is None:
40 | value = read_secret_from_file(environment_variable_name)
41 | if value is None:
42 | raise ValueError(environment_variable_name)
43 | return value
44 |
45 |
46 | # AUTHENTICATION SECRET
47 | API_AUTH_SECRET = get_environment_variable(API_AUTH_SECRET_ENVIRONMENT_VARIABLE)
48 | API_AUTH_SECRET_HEADER_NAME = "API_AUTH_SECRET"
49 |
50 |
51 | def configure(binder: Binder) -> None:
52 | azure_config = get_azure_config()
53 | workspace = azure_config.get_workspace()
54 | binder.bind(Workspace, to=workspace, scope=singleton)
55 | binder.bind(AzureConfig, to=azure_config, scope=singleton)
56 |
57 |
58 | def get_azure_config() -> AzureConfig:
59 | return AzureConfig(cluster=get_environment_variable(CLUSTER),
60 | workspace_name=get_environment_variable(WORKSPACE_NAME),
61 | experiment_name=get_environment_variable(EXPERIMENT_NAME),
62 | resource_group=get_environment_variable(RESOURCE_GROUP),
63 | subscription_id=get_environment_variable(SUBSCRIPTION_ID),
64 | application_id=get_environment_variable(APPLICATION_ID),
65 | service_principal_secret=get_environment_variable(
66 | AZUREML_SERVICE_PRINCIPAL_SECRET_ENVIRONMENT_VARIABLE),
67 | tenant_id=get_environment_variable(TENANT_ID),
68 | datastore_name=get_environment_variable(DATASTORE_NAME),
69 | image_data_folder=get_environment_variable(IMAGE_DATA_FOLDER))
70 |
--------------------------------------------------------------------------------
/conftest.py:
--------------------------------------------------------------------------------
1 | """
2 | Global PyTest configuration -- used to define global fixtures for the entire test suite
3 |
4 | DO NOT RENAME THIS FILE: (https://docs.pytest.org/en/latest/fixture.html#sharing-a-fixture-across-tests-in-a-module
5 | -or-class-session)
6 | """
7 |
--------------------------------------------------------------------------------
/dockerignore:
--------------------------------------------------------------------------------
1 | # See https://docs.docker.com/engine/reference/builder/#dockerignore-file
2 | # Put files here that you don't want copied into your bundle's invocation image
3 | .gitignore
4 | Dockerfile.tmpl
5 | *.dcm
6 |
7 | # IGNORE THESE FILES FOR CNAB GENERATION
8 | azure-stack-profile.txt
9 | azure-stack-profile.template.txt
10 | porter-helpers.sh
11 | README.md
12 | ReadMe.txt
13 | Start.md
14 |
--------------------------------------------------------------------------------
/docs/BugReporting.md:
--------------------------------------------------------------------------------
1 | # Guidelines for how to report bug
2 |
3 | @Mark, @Ivan
4 |
5 |
--------------------------------------------------------------------------------
/docs/DeployResourcesAzureStackHub.md:
--------------------------------------------------------------------------------
1 | # Deploying Resources on Azure Stack Hub
2 |
3 | ## Description
4 | This document highlights how to deploy the InnerEye inferencing solution to an Azure Stack Hub subscription. The solution has been packaged as a Cloud Native Application Bundle (CNAB) for deployment purposes. The sections below cover the necessary steps required to deploy the CNAB package to your Azure Stack Hub environment.
5 |
6 | ## Prerequisites
7 | - Azure Stack Hub subscription
8 | - Docker (Here is a link if you need to install [Docker Installation Instructions](https://docs.docker.com/get-docker/))
9 | - Porter (Here is a link if you need to install: [Porter Installation Instructions](https://porter.sh/install/))
10 | > **NOTE:** be sure to add porter to your PATH
11 | - Service Principal that has been granted contributor access to your Azure Stack Hub subscription
12 | - You will need the following information for the service principal
13 | - Client ID
14 | - Client secret
15 | - Object ID (this is different than the application id and can be found on the enterpise application area of Azure Active Directory)
16 | - Tenant ID
17 | - Your user account needs to have owner access to the subscription. This is required for assigning access to the service principal for resource deployment.
18 |
19 | ## Step 1: Prepare for Installation
20 |
21 | ### Create CNAB Parameter File
22 |
23 | Locate the file named `azure-stack-profile.template.txt` and open it for editing. You will need to provide some values so the CNAB package can register your Azure Stack environment and deploy into it. After assigning the required values, save the file as `azure-stack-profile.txt` .
24 |
25 | ```
26 | azure_stack_tenant_arm="Your Azure Stack Tenant Endpoint"
27 | azure_stack_storage_suffix="Your Azure Stack Storage Suffix"
28 | azure_stack_keyvault_suffix="Your Azure Stack KeyVault Suffix"
29 | azure_stack_location="Your Azure Stack’s location identifier here."
30 | azure_stack_resource_group="Your desired Azure Stack resource group name to create"
31 | ```
32 | ### Generate Credentials
33 | Open a new shell window and make sure you are in the root directory of this repo. Run the command below to generate credentials required for deployment. Follow the prompts to assign values for the credentials needed. Select "specific value" from the interactive menu for each of the required credential fields. A description of each credential is provided below.
34 |
35 | ```sh
36 | porter generate credentials
37 | ```
38 |
39 | |Item|Description|
40 | |----|-----------|
41 | |AZURE_STACK_SP_CLIENT_ID|The client id for the service principal that is registered with your Azure Stack Hub Subscription|
42 | |AZURE_STACK_SP_PASSWORD|The secret associated with the service principal that is registered with your Azure Stack Hub Subscription|
43 | |AZURE_STACK_SP_TENANT_DNS|The DNS for the Azure Active Directory that is tied to your Azure Stack Hub (e.g. mycomany.onmicrosoft.com)|
44 | |AZURE_STACK_SUBSCRIPTION_ID|The subscription id for the subscription on your Azure Stack Hub that you want to deploy into|
45 | |VM_PASSWORD|The password you would like to use for the login to the VM that is deployed as part of this CNAB package|
46 |
47 | ## Step 2: Build CNAB
48 |
49 | Run the command below to build the Porter CNAB package. This step builds the docker invocation image required for executing the CNAB installation steps.
50 |
51 | ```sh
52 | porter build
53 | ```
54 |
55 | ## Step 3: Install CNAB
56 |
57 | ### Install CNAB Package
58 | Run the below command to install the CNAB package. This will create a new resource group on you Azure Stack subscription and will deploy the solution into it.
59 |
60 | ```sh
61 | porter install InnerEyeInferencing --cred InnerEyeInferencing --param-file "azure-stack-profile.txt"
62 | ```
63 |
64 | ### (Optional) Uninstall CNAB Package
65 | If you wish to remove the solution from your Azure Stack Hub, run the below command. Please note that this will delete the entire resource group that the solution was deployed into. If you have created any other custom resources in this resource group, they will also be deleted.
66 |
67 | ```sh
68 | porter uninstall InnerEyeInferencing --cred InnerEyeInferencing --param-file "azure-stack-profile.txt"
69 | ```
--------------------------------------------------------------------------------
/docs/EndToEndDemo.md:
--------------------------------------------------------------------------------
1 | # How to run end to end demo on local environment?
2 |
3 | Here are some quick steps to run end to end demo on your local environment.
4 |
5 | Do SSH into the GPU VM, first command is docker images to get the image id of the modified head and neck container. Then run it interactively using
6 |
7 | 1. Start the GPU VM which has Inferencing container. Get the public IP and copy it.
8 | 2. Do SSH to this VM using - SSH :IP address
9 | 3. If prompted enter "yes"
10 | 4. Now it will ask for password. Enter the password:
11 | 5. After successful login it will open the VM shell. In the shell run below command.
12 | 6. docker run -it --entrypoint=/bin/bash -p 8086:5000 -e AZURE_STORAGE_ACCOUNT_NAME=name -e AZURE_STORAGE_KEY= -e AZURE_STORAGE_ENDPOINT= --gpus all
13 | 7. conda activate nnenv
14 | 8. python web-api.py
15 | 9. Clone https://github.com/microsoft/InnerEye-gateway
16 | 10. Clone https://github.com/microsoft/InnerEye-inference
17 | 11. Set platform to x64 and build the project
18 | 12. Generate self signed certificate using below command in PowerShell window. Make sure you run it as Administrator.
19 | `New-SelfSignedCertificate -CertStoreLocation Cert:\LocalMachine\My -DnsName "mysite.local" -FriendlyName "InnerEyeDryRun" -NotAfter (Get-Date).AddYears(10)`
20 | 13. Copy the thumbprint and replace "KeyVaultAuthCertThumbprint" key value of Inferencing API and Worker Project in config file.
21 | a. Microsoft.InnerEye.Azure.Segmentation.API.Console
22 | b. Microsoft.InnerEye.Azure.Segmentation.Worker.Console
23 | 14. Replace the other keys in same file.
24 | 15. Build both projects.
25 | 16. Now run both project Inferencing API and Engine exe. from bin directory
26 | a. Microsoft.InnerEye.Azure.Segmentation.Worker.Console.exe
27 | b. Microsoft.InnerEye.Azure.Segmentation.API.Console.exe
28 | 17. Next thing is to run gateway receiver and processor:
29 | a. Microsoft.InnerEye.Listener.Processor.exe
30 | b. Microsoft.InnerEye.Listener.Receiver.exe
31 | 18. Now you have to navigate to images folder
32 | 19. Open path in PowerShell window.
33 | 20. Run these command on PowerShell - `storescu 172.16.0.5 104 -v --scan-directories -aec RGPelvisCT -aet Scanner .`
34 | 21. Open a suitable path in PowerShell where you want to store result.
35 | 22. Run on powershell `storescp 1105 -v -aet PACS -od . --sort-on-study-uid st`
36 | 23. Wait for results.
37 |
38 |
--------------------------------------------------------------------------------
/docs/InferencingAPIs.md:
--------------------------------------------------------------------------------
1 | # Inferencing APIs
2 |
3 | ## Gateway Dicom – Inferencing API
4 |
5 | Inferencing API is one the main component of the Inner Eye architecture. Currently we have set of API calls, which are grouped into several functional groups and its part of InnerEye Cloud (classic cloud service) application.
6 | (As part of architecture, Inferencing API is highlighted as below)
7 |
8 | 
9 |
10 | Below is the distribution of set of API call into as per their functional groups. Out of which we are working on Point 4 Inferencing API also test Point 5 for health check.
11 |
12 | **1. DICOM Configuration**
13 |
14 | These APIs configure DICOM endpoints that the Gateway can work with as well as routing rules for data that comes from these endpoints.
15 | These APIs configure DICOM endpoints that the Gateway can work with as well as routing rules for data that comes from these endpoints.
16 | **OSS implementation:** the configuration will be done via JSON files. These APIs are scrapped.
17 |
18 | */api/v1/config/gateway/update* - Gets the expected version the Gateway should be updated to.
19 | */api/v1/config/gateway/receive* - Gets the current gateway "receive" configuration.
20 | */api/v1/config/gateway/processor* - Gets the current gateway "processor" configuration.
21 | */api/v1/config/gateway/destination/{callingAET}* - Gets the destination DicomEndPoint to send results to given the AET of the caller
22 | */api/v1/config/gateway/destination/{callingAET}/{calledAET}* - Gets the destination DicomEndPoint to send results to given the AET of the caller and the calling AET (the way our gateway is being called)
23 | */api/v1/config/aetconfig/{calledAET}/{callingAET}* - Download a collection of DICOM constraints based on called AET and calling AET
24 |
25 | **2. Data Upload**
26 |
27 | This API endpoint provides a way to upload data for persisting the images for subsequent machine learning.
28 | **OSS implementation:** These API need to be updated to conform with DICOMWeb implementation.
29 | */api/v1/storage* - Upload DICOM series to long term storage. In V1 this API call needs to be replaced with a call to a DICOM Web STOW-RS
30 |
31 | **3. Feedback**
32 |
33 | These APIs facilitate a workflow where a corrected segmentation is sent back for further analysis. This is not used in V1; the APIs below should be removed.
34 | OSS implementation: These API need to be removed
35 | */api/v1/feedback* - Upload a collection of DICOM files (segmentation masks).
36 | */ping* - check if API is still up. Keep for V1
37 | */api/ping* - check if API is still up, with authentication. Remove for V1
38 |
39 | **4. Inferencing**
40 |
41 | These APIs have to do with inferencing:
42 | • Get the list of registered models
43 | • Send image for inferencing
44 | • Get progress
45 | • Retrieve result
46 |
47 | **OSS implementation:** Most of these APIs remain and are essential to V1 operation
48 | **/api/v1/models** - Returns a list of all models from Azure model blob container. This call is not needed for V1 implementation. This part was under discussion and based on meetings and discussion, for demos we are going to used two static model configurations.
49 | **/api/v1/models/{modelId}/segmentation/{segmentationId}** - Checks the segmentation status for a given segmentation of a given model.
50 | **/api/v1/models/{modelId}/segmentation/{segmentationId}/result** - Gets the result for a completed segmentation.
51 | **/api/v1/models/{modelId}/segmentation** - Starts a segmentation. The content of the request should be a compressed zip file with a list of DICOM files with a folder per ChannelId E.g. ct\1.dcm, flair\1.dcm
52 |
53 | **5. Health check**
54 |
55 | */ping* - check if API is still up. Keep for V1
56 | */api/ping* - check if API is still up, with authentication. Remove for V1
--------------------------------------------------------------------------------
/docs/InferencingContainer.md:
--------------------------------------------------------------------------------
1 | # Inferencing Container
2 |
3 | @Mark, @Edwin - Help out
--------------------------------------------------------------------------------
/docs/InferencingEngine.md:
--------------------------------------------------------------------------------
1 | # Inferencing Engine
2 |
3 | **What does Inferencing do and the flow related to the architecture?**
4 |
5 | Inferencing Engine works to transform the Nifti images to Dicom RT files. Inferencing Engine reads the Dicom Image from the Queue which is present in the byte form. The image is transformed into nifty image and pushes it to the Inferencing container where it send back the Nifti seg mask image. The Segmentation processor take the nifti images and transforms to Dicom RT file. These Dicom RT images are pushed to blob and the progress of this task is saved in Table storage.
6 |
7 | @Mark - To help out
--------------------------------------------------------------------------------
/docs/MoreAboutInnerEyeProject.md:
--------------------------------------------------------------------------------
1 | # More about InnerEye project
2 |
3 | @Michela
--------------------------------------------------------------------------------
/docs/PullRequestsGuidelines.md:
--------------------------------------------------------------------------------
1 | # Pull Requests guidelines
2 |
3 | @Mark, @Ivan
--------------------------------------------------------------------------------
/docs/Setup.md:
--------------------------------------------------------------------------------
1 | # Getting started
2 | Here is a page with intros to DICOM and subject domain: [TBD]
3 |
4 | # Chapter 1: Resource deployment
5 | When it comes to resources deployment on Azure Environment we mostly think about using ARM templates, which is the good thing which stack hub also supports. We can deploy our resources using ARM template. In this setup we are taking help of [CNAB](https://cnab.io/) to bundle our infrastructure and deploy it on Azure Stack Hub.
6 |
7 | # Prerequisites
8 |
9 | - Azure Stack Hub subscription
10 |
11 | - Docker (Here is a link if you need to install [Docker Installation Instructions](https://docs.docker.com/get-docker/) )
12 |
13 | - Porter (Here is a link if you need to install: Porter Installation Instructions [Porter Installation Instructions](https://porter.sh/install/))
14 |
15 | > **NOTE:** be sure to add porter to your PATH
16 |
17 | - Service Principal that has been granted contributor access to your Azure Stack Hub subscription
18 |
19 | - You will need the following information for the service principal
20 | - Client ID
21 | - Client secret
22 | - Object ID (this is different than the object id and can be found on the enterprise application area of your Azure Active Directory)
23 | - Tenant ID
24 |
25 | - Your user account needs to have owner access to the subscription. (This is needed to assign access to the service principal for deployment)
26 |
27 | # Step 1: Prepare for Installation
28 |
29 | ### Create CNAB Parameter File
30 |
31 | Locate the file named `azure-stack-profile.template.txt` and open it for editing. You will need to provide some values so the CNAB package can register your Azure Stack environment and deploy into it. Save the file as `azure-stack-profile.txt` after you have assigned the required values.
32 |
33 | ```
34 | azure_stack_tenant_arm="Your Azure Stack Tenant Endpoint"
35 | azure_stack_storage_suffix="Your Azure Stack Storage Suffix"
36 | azure_stack_keyvault_suffix="Your Azure Stack KeyVault Suffix"
37 | azure_stack_location="Your Azure Stack’s location identifier here."
38 | azure_stack_resource_group="Your desired Azure Stack resource group name to create"
39 | slicer_ip="IP address for your clinical endpoint for receiving DICOM RT"
40 | ```
41 |
42 | ### Generate Credentials
43 |
44 | Open a new shell window and make sure you are in the root directory of this repo. Run the command below to generate credentials required for deployment. Follow the prompts to assign values for the credentials needed. Select "specific value" from the interactive menu for each of the required credential fields. A description of each credential is provided below.
45 |
46 | ```
47 | porter credentials generate
48 | ```
49 |
50 | | Item | Description |
51 | | :-------------------------- | :----------------------------------------------------------- |
52 | | AZURE_STACK_SP_CLIENT_ID | The client id for the service principal that is registered with your Azure Stack Hub Subscription |
53 | | AZURE_STACK_SP_PASSWORD | The secret associated with the service principal that is registered with your Azure Stack Hub Subscription |
54 | | AZURE_STACK_SP_TENANT_DNS | The dns for the Azure Active Directory that is tied to your Azure Stack Hub (e.g. [mycompany.onmicrosoft.com](http://mycompany.onmicrosoft.com/) ) |
55 | | AZURE_STACK_SUBSCRIPTION_ID | The subscription id for the subscription on your Azure Stack Hub that you want to deploy into |
56 | | VM_PASSWORD | The password you would like to use for the login to the VM that is deployed as part of this CNAB package |
57 |
58 | # Step 2: Build CNAB
59 |
60 | Run the command below to build the Porter CNAB package.
61 |
62 | ```
63 | porter build
64 | ```
65 |
66 | # Step 3: Install CNAB
67 |
68 | ### Install CNAB Package
69 |
70 | Run the below command to install the CNAB package. This will create a new resource group on you Azure Stack subscription and will deploy the solution into it.
71 |
72 | ```
73 | porter install InnerEyeDemo --cred InnerEyeDemo --param-file "azure-stack-profile.txt"
74 | ```
75 |
76 | ### (Optional) Uninstall CNAB Package
77 |
78 | If you wish to remove the solution from your Azure Stack Hub, run the below command. Please note that this will delete the entire resource group that the solution was deployed into. If you have created any other custom resources in this resource group, they will also be deleted.
79 |
80 | ```
81 | porter uninstall InnerEyeDemo --cred InnerEyeDemo --param-file "azure-stack-profile.txt"
82 | ```
83 |
84 | # Step 4: Start Inferencing Container(s)
85 |
86 | - Get the IP of the Inferencing Container VM from the Azure Stack Hub Portal
87 | - Make any necessary modifications to the model_inference_config.json file in ModelServer directory of In Inferencing Repo
88 | - Copy the ModelServer directory from root of Inferencing Repo to Inferencing Container VM
89 | ```
90 | scp -r ModelServer @:/home//app
91 | ```
92 | - Connect to the VM via ssh
93 | - Navigate to the app directory
94 | - Start the containers by running the below commands
95 |
96 | ```
97 | python setup-inferencing.py model_inference_config.json
98 | ```
99 |
100 | # Step 5: Start the Gateway
101 |
102 | - Get the IP of the Inferencing Container VM from the Azure Stack Hub Portal
103 | - Connect to the VM via Remote Desktop Protocol (RDP)
104 | - Open the gateway.msi file on the desktop
105 |
106 | ## Summary of Deployment Components
107 |
108 | - KeyVault and grants read access to Service Principal
109 | - Storage Account
110 | - GPU Linux VM to host inferencing containers
111 | - App service plan to host Inferencing API and Inferencing Engine
112 | - Inferencing API app service
113 | - Inferencing Engine app service
114 | - Gateway VM
115 |
116 | # Chapter 2: Building and deploying the code
117 | We can always use Azure DevOps CICD pipelines to build and deploy the code on Infrastructure created. In this chapter we will talk about how to build and deploy code using local environment.
118 |
119 | # Prerequisites
120 |
121 | - Cloned git repositories.
122 | - Visual Studio 2017
123 | - Azure Stack Hub Storage Account Details
124 | - Gateway VM Details
125 | - Inferencing container Details
126 |
127 | ### Clone the repos
128 |
129 | To clone the code to local environment please follow below steps:
130 |
131 | 1. First you need to clone the InnerEye Cloud Solution.
132 |
133 | ```
134 | git clone https://msdsip@dev.azure.com/msdsip/AshInnerEye/_git/AshInnerEye
135 | ```
136 |
137 | 2. After cloning the InnerEye Cloud Solution, second repository to clone is Gateway.
138 |
139 | ```
140 | git clone https://msdsip@dev.azure.com/msdsip/AshInnerEye/_git/Gateway
141 | ```
142 |
143 | - > **NOTE:** Make sure you clone both the repos at root folder of any directory, this is to avoid max-length path issue.
144 |
145 | ### Building the solutions
146 |
147 | #### Building InnerEye Cloud Solution
148 |
149 | 1. Open Visual Studio 2017
150 |
151 | 2. Open InnerEye Cloud.sln to open InnerEye Cloud Solution from the repo cloned.
152 |
153 | 3. Once opened, set the solution configuration to x64.
154 |
155 | 4. Open Web.config for Microsoft.InnerEye.Azure.Segmentation.API.AppService
156 |
157 | 5. Update the following app settings
158 |
159 | ```
160 |
161 |
162 |
163 |
164 |
165 |
166 |
167 |
168 | ```
169 |
170 | Here:
171 |
172 | 1. AccoutName is storage Account Name of Azure Stack Hub
173 | 2. StorageConnectionString is Connection string of Azure Stack Hub storage account.
174 |
175 | 6. Once this is updated. once the Web.Config for Microsoft.InnerEye.Azure.Segmentation.Worker.AppService
176 |
177 | ```
178 |
179 |
180 |
181 |
182 |
183 |
184 |
185 |
186 |
187 |
188 | ```
189 |
190 | Here:
191 |
192 | 1. AccountName is storage Account Name of Azure Stack Hub
193 | 2. StorageConnectionString is Connection string of Azure Stack Hub storage account.
194 | 3. InferencingContainerEndpoint is IP address of Inferencing container VM
195 | 4. InferencingContainerEndpointPort is port number where Inferencing container is hosted.
196 |
197 | 7. When both Web.config files are ready build the solution from the build menu of Visual Studio.
198 |
199 | #### Building Gateway Solution
200 |
201 | 1. Open the new instance of Visual Studio 2017
202 | 2. Open Microsoft.Gateway.sln from the Gateway repo cloned.
203 | 3. Modify the InnerEyeSegementationClient.cs to add inferencing API endpoint.
204 | 4. Set the project configuration to x64
205 | 5. Build the solution from the build menu of Visual Studio.
206 |
207 | ### Deploying the solutions
208 |
209 | #### Deploying InnerEye Cloud Solution
210 |
211 | 1. Either you can download Publish Profile of deployed Inferencing API or you can also create new one using Visual Studio Publish option.
212 | 2. To download publish profile go to Azure Stack Hub portal and open Inferencing API resource.
213 | 3. Click on Get Publish profile button from overview page.
214 | 4. Once downloaded switch to Visual Studio window which has InnerEye Cloud solution opened.
215 | 5. Right click on Microsoft.InnerEye.Azure.Segmentation.API.AppService and select Publish.
216 | 6. Import the downloaded publish profile.
217 | 7. Set the release configuration to x64 and click publish.
218 | 8. This will deploy the Microsoft.InnerEye.Azure.Segmentation.API.AppService to hub.
219 | 9. Now switch to browser window and open Inferencing Engine App Service.
220 | 10. Once open go to overview page and download the publish profile by clicking on Get Publish Profile button.
221 | 11. Once downloaded right click on Microsoft.InnerEye.Azure.Segmentation.Worker.AppService and click publish.
222 | 12. Import the downloaded publish profile.
223 | 13. Set the release configuration to x64 and click Publish.
224 | 14. This will publish Microsoft.InnerEye.Azure.Segmentation.Worker.AppService
225 | 15. Open Azure Stack portal and go to configuration settings for both above app services.
226 | 16. Click on general settings tab.
227 | 17. Set platform to x64.
228 | 18. Set Always On to true.
229 | 19. Save the changes on both app services and restart them.
230 |
231 | #### Deploying Gateway Solution
232 |
233 | 1. Gateway needs to run as Windows Service but you can also run executables of Gateway solution independently.
234 | 2. To run gateway from installer copy the build contents from Gateway solution to the Virtual Machine dedicated for Gateway.
235 | 3. Search and open Microsoft.InnerEye.Gateway.msi
236 | 4. This will install Gateway as Windows Service in virtual machine.
237 | 5. If you do not want to run Gateway Services as Windows Service then
238 | 6. From the build contents go to Microsoft.InnerEye.Listener.Processor and look for executable.
239 | 7. Open the executable file.
240 | 8. Go to Microsoft.InnerEye.Listener.Receiver and look for executable.
241 | 9. Open the executable.
242 |
243 | # Chapter 3: Testing and Configuring Deployed Environment
244 |
245 | Below are the instructions to debug and monitored resources after the solution has been deployed below.
246 |
247 | ## VM: Inferencing container (Linux)
248 |
249 | Once the machine is running, ensure that the _headandneckmodel_ container is running by running `docker ps` and ensuring you have the container "_headandneckmodel_" up. If everything is up you should be able to navigate to [YOUR IP] and see output there.
250 |
251 | If the container is not running or needs to be restarted, run `docker kill` and then do the following:
252 |
253 | 1. Run `docker run -it --entrypoint=/bin/bash -p 8081:5000 -e AZURE_STORAGE_ACCOUNT_NAME= -e AZURE_STORAGE_ENDPOINT= --gpus all headandneckmodel:latest`
254 | 2. Run `conda activate nnenv`
255 | 3. Run `python web-api.py` - this should launch the web server that is wrapping the inferencing container
256 |
257 | If the container is already running:
258 | * Use `docker logs ` to retrieve logs from the container shell
259 | * Use `docker attach ` to connect to the interactive shell of the inferencing container
260 |
261 | ## Inferencing Engine AppService
262 | Runs model invoker. Makes calls to the model server. Converts from DICOM to NIFTI.
263 |
264 | In sample environment runs on: app-inferapi
265 |
266 | ## Inferencing API AppService
267 | Runs API, kicks off model inferencing via communication with the inferencing engine.
268 |
269 | In sample environment runs on: infereng
270 |
271 | ## Gateway VM (Windows)
272 |
273 | Gateway should be launched like so:
274 | 1. **GatewayProcessor** *TBD: Windows Service Launch*
275 | 2. **GatewayReceiver** *TBD: Windows Service Launch*
276 |
277 | Before launching make sure that the API App Service is running as the Gateway checks its health first and wouldn't start if it can't find the app service.
278 |
279 | Watch startup logs to make sure there are no errors. Note that the inferencing container should have its server running before the segmentation processor and API are launched.
280 |
281 | ### Gateway Configuration
282 |
283 | Gateway configuration files are located in: *TBD*
284 | **TODO** Server URL needs to be configured. For now hardcoded in InnerEyeSegmentationClient.cs, EnvironmentBaseAddress
285 |
286 | ## Storage account
287 | **Account name**: [TBD]
288 |
289 | ### Containers
290 | Used to store segmentations coming in (converted to NIFTI) and out (converted to NIFTI). One container is created per segmentation request
291 |
292 | #### Tables
293 |
294 | | Name | Purpose |
295 | | ----------------- | ------------------------------------------------------------ |
296 | | gatewayversions | not in use |
297 | | imagesindexes | not in use |
298 | | models | not in use (will be used after model configuration is read from the AML package) |
299 | | progresstable | contains progress per segmentation, updated by cloud code. PartitionKey - model ID; RowKey - segmentation ID |
300 | | rtfeedbackindexes | not in use |
301 | | rtindexes | ?? |
302 | | TimingsTable | Used to compute progress by storing average time of multiple model runs |
303 |
304 | # Chapter 4. Running the Demo
305 |
306 | In order to run the demo, ensure that all services are running by launching them in the following order:
307 |
308 | 1. Inferencing container and API
309 | 2. Gateway
310 |
311 |
--------------------------------------------------------------------------------
/docs/Stack-overflowAndOtherChannels.md:
--------------------------------------------------------------------------------
1 | # Tag to be use on stack-overflow and other channels
2 |
3 | @Mark, @Ivan
--------------------------------------------------------------------------------
/docs/VideoAndBlogs.md:
--------------------------------------------------------------------------------
1 | # Video and Blogs
2 |
3 | @Michela
--------------------------------------------------------------------------------
/docs/WAF_setup.md:
--------------------------------------------------------------------------------
1 | # OWASP-Compliant Inference Service
2 |
3 | For increased security on your Inference service, you may want to deploy it behind an Application Gateway running a Web Application Firewall (WAF). WAFs can be configured to filter all traffic to and from your application service by ensuring it conforms to certain standards. This tutorial will detail how to set up an Inference Service behind a WAF enforcing the [OWASP 3.0 Core Rule Set (CRS)](https://learn.microsoft.com/en-us/azure/web-application-firewall/ag/application-gateway-crs-rulegroups-rules?tabs=owasp32#owasp-crs-30).
4 |
5 | This tutorial assumes a basics familiarity with Application Gateways. You can read about them on [Microsoft Learn](https://learn.microsoft.com/en-us/azure/application-gateway/overview#features) for a quick overview.
6 |
7 | > *It should be noted that all traffic between the InnerEye-Gateway and InnerEye-Inference service is* already *OWASP compliant. This tutorial simply shows you how to set up a WAF that ensures any traffic coming from other sources also conforms to this standard, in order to reduce the risk of malicious traffic hitting your InnerEye-Inference app service endpoints.*
8 |
9 | ## Steps
10 |
11 | ### 1. Create Application Gateway
12 |
13 | Firstly, you will need to create an Application Gateway. This can be done by following the tutorial linked below, with 1 important change:
14 |
15 | - **During the "Basics tab" section, select the tier "WAF", not the tier "WAF V2".**
16 |
17 | While in theory the WAF V2 version should work perfectly well, it has not been tested by our team and we cannot guarantee functionality.
18 |
19 | To set up your Application Gateway, carry out the steps in "Create an Application Gateway" in [this tutorial](https://learn.microsoft.com/en-us/azure/web-application-firewall/ag/application-gateway-web-application-firewall-portal#create-an-application-gateway), selecting a different tier as described above.
20 |
21 | ### 2. Create your Inference App Service
22 |
23 | If you've not already done so, deploy your Inference App Service by following the steps in the [README](https://github.com/microsoft/InnerEye-Inference/#running-flask-app-in-azure).
24 |
25 | ### 3. Link Inference Service to Application Gateway Backend
26 |
27 | Follow the steps in the "Add App Service as Backend Pool" section of [this tutorial](https://learn.microsoft.com/en-us/azure/application-gateway/configure-web-app?tabs=customdomain%2Cazure-portal#add-app-service-as-backend-pool) to add your deployed InnerEye-Inference App Service to the backend pool of your Application Gateway.
28 |
29 | Presuming you were successful with all the previous steps, you should now be able to see your App Service endpoint reporting as healthy under "Monitoring -> Backend Health" on your Application Gateway.
30 |
31 | ### 4. Set Up WAF
32 |
33 | - In the Azure Portal, navigate to your newly created Gateway and select "Web Application Firewall" under "Settings" on the left side of the page.
34 | - Under the "Rules" tab, ensure that the OWASP 3.0 rule set is being used.
35 | - Under the "Configure" tab:
36 | - Ensure that the "WAF Mode" has been set to "Prevention".
37 | - Under exclusions, add the following row:
38 | - Field = "Request header name"
39 | - Operator = "Equals"
40 | - Selector = "Content-Type"
41 |
42 | Your WAF "Configure" page should now look like this:
43 |
44 | 
45 |
46 | Once these settings are saved, your WAF will now block any traffic that does not conform to the OWASP 3.0 rules.
47 |
48 | ### 5. Test with InnerEye Gateway
49 |
50 | The easiest way to comprehensively test your WAF + Inference Deployment is using the [InnerEye-Gateway](https://github.com/microsoft/InnerEye-Gateway). To do this, please carry out the setup steps in the InnerEye-Gateway README before then running the [end-to-end manual tests](https://github.com/microsoft/InnerEye-Gateway#to-run-the-tests), pointing your InnerEye-Gateway to the frontend IP exposed by your Application Gateway.
51 |
--------------------------------------------------------------------------------
/docs/WAF_setup.png:
--------------------------------------------------------------------------------
1 | version https://git-lfs.github.com/spec/v1
2 | oid sha256:c416c2904d15cf42cb0876178d842de2f6f2108b7aca8d070df5dec346ca88be
3 | size 49526
4 |
--------------------------------------------------------------------------------
/download_model_and_run_scoring.py:
--------------------------------------------------------------------------------
1 | # ------------------------------------------------------------------------------------------
2 | # Copyright (c) Microsoft Corporation. All rights reserved.
3 | # Licensed under the MIT License (MIT). See LICENSE in the repo root for license information.
4 | # ------------------------------------------------------------------------------------------
5 |
6 | import argparse
7 | import os
8 | import subprocess
9 | import sys
10 | from pathlib import Path
11 | from typing import Dict, List, Tuple
12 |
13 | from azureml.core import Model, Run, Datastore
14 |
15 |
16 | DELETED_IMAGE_DATA_NOTIFICATION = "image data deleted"
17 |
18 |
19 | def spawn_and_monitor_subprocess(process: str, args: List[str], env: Dict[str, str]) -> Tuple[int, List[str]]:
20 | """
21 | Helper function to spawn and monitor subprocesses.
22 | :param process: The name or path of the process to spawn.
23 | :param args: The args to the process.
24 | :param env: The environment variables for the process (default is the environment variables of the parent).
25 | :return: Return code after the process has finished, and the list of lines that were written to stdout by the subprocess.
26 | """
27 | p = subprocess.Popen(
28 | [process] + args,
29 | shell=False,
30 | stdout=subprocess.PIPE,
31 | stderr=subprocess.STDOUT,
32 | env=env
33 | )
34 |
35 | # Read and print all the lines that are printed by the subprocess
36 | stdout_lines = [line.decode('UTF-8').strip() for line in p.stdout] # type: ignore
37 | for line in stdout_lines:
38 | print(line)
39 |
40 | # return the subprocess error code to the calling job so that it is reported to AzureML
41 | return p.wait(), stdout_lines
42 |
43 |
44 | def run() -> None:
45 | """
46 | This script is run in an AzureML experiment which was submitted by submit_for_inference.
47 |
48 | It downloads a model from AzureML, and starts the score script (usually score.py) which is in the root
49 | folder of the model. The image data zip is are downloaded from the AzureML datastore where it was copied
50 | by submit_for_inference. Once scoring is completed the image data zip is overwritten with some simple
51 | text in lieue of there being a delete method in the AzureML datastore API. This ensure that the run does
52 | not retain images.
53 | """
54 | parser = argparse.ArgumentParser(description='Execute code inside of an AzureML model')
55 | parser.add_argument('--model_id', dest='model_id', action='store', type=str, required=True,
56 | help='AzureML model ID')
57 | parser.add_argument('--script_name', dest='script_name', action='store', type=str, required=True,
58 | help='Name of the script in the model that will produce the image scores')
59 | parser.add_argument('--datastore_name', dest='datastore_name', action='store', type=str, required=True,
60 | help='Name of the datastore where the image data zip has been copied')
61 | parser.add_argument('--datastore_image_path', dest='datastore_image_path', action='store', type=str, required=True,
62 | help='Path to the image data zip copied to the datastore')
63 | known_args, _ = parser.parse_known_args()
64 |
65 | current_run = Run.get_context()
66 | if not hasattr(current_run, 'experiment'):
67 | raise ValueError("This script must run in an AzureML experiment")
68 |
69 | workspace = current_run.experiment.workspace
70 | model = Model(workspace=workspace, id=known_args.model_id)
71 |
72 | # Download the model from AzureML
73 | here = Path.cwd().absolute()
74 | model_path = Path(model.download(here)).absolute()
75 |
76 | # Download the image data zip from the named datastore where it was copied by submit_for_infernece
77 | # We copy it to a data store, rather than using the AzureML experiment's snapshot, so that we can
78 | # overwrite it after the inference and thus not retain image data.
79 | image_datastore = Datastore(workspace, known_args.datastore_name)
80 | prefix = str(Path(known_args.datastore_image_path).parent)
81 | image_datastore.download(target_path=here, prefix=prefix, overwrite=False, show_progress=False)
82 | downloaded_image_path = here / known_args.datastore_image_path
83 |
84 | env = dict(os.environ.items())
85 | # Work around https://github.com/pytorch/pytorch/issues/37377
86 | env['MKL_SERVICE_FORCE_INTEL'] = '1'
87 | # The model should include all necessary code, hence point the Python path to its root folder.
88 | env['PYTHONPATH'] = str(model_path)
89 |
90 | score_script = model_path / known_args.script_name
91 | score_args = [
92 | str(score_script),
93 | '--data_folder', str(here / Path(known_args.datastore_image_path).parent),
94 | '--image_files', str(downloaded_image_path),
95 | '--model_id', known_args.model_id,
96 | '--use_dicom', 'True']
97 |
98 | if not score_script.exists():
99 | raise ValueError(
100 | f"The specified entry script {known_args.script_name} does not exist in {model_path}")
101 |
102 | print(f"Starting Python with these arguments: {score_args}")
103 | try:
104 | code, stdout = spawn_and_monitor_subprocess(process=sys.executable, args=score_args, env=env)
105 | finally:
106 | # Delete image data zip locally
107 | downloaded_image_path.unlink()
108 | # Overwrite image data zip in datastore. The datastore API does not (yet) include deletion
109 | # and so we overwrite the image data zip with a short piece of text instead. Later these
110 | # overwritten image data zip files can be erased, we recommend using a blobstore lifecylce
111 | # management policy to delete them after a period of time, e.g. seven days.
112 | downloaded_image_path.write_text(DELETED_IMAGE_DATA_NOTIFICATION)
113 | image_datastore.upload_files(files=[str(downloaded_image_path)], target_path=prefix, overwrite=True, show_progress=False)
114 | # Delete the overwritten image data zip locally
115 | downloaded_image_path.unlink()
116 | if code != 0:
117 | print(f"Python terminated with exit code {code}. Stdout: {os.linesep.join(stdout)}")
118 | sys.exit(code)
119 |
120 |
121 | if __name__ == '__main__':
122 | run()
123 |
--------------------------------------------------------------------------------
/environment.yml:
--------------------------------------------------------------------------------
1 | name: inference
2 | dependencies:
3 | - python=3.7.15
4 | - pip
5 | - pip:
6 | - -r requirements.txt
7 |
--------------------------------------------------------------------------------
/mypy.ini:
--------------------------------------------------------------------------------
1 | [mypy]
2 | python_version=3.7
3 | scripts_are_modules=True
4 | namespace_packages=True
5 | show_traceback=True
6 | ignore_missing_imports=True
7 | follow_imports=normal
8 | follow_imports_for_stubs=True
9 | disallow_untyped_calls=False
10 | disallow_untyped_defs=True
11 | strict_optional=True
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | azureml-sdk==1.41.0
2 | flake8==3.8.4
3 | flask-injector==0.13.0
4 | Flask==2.3.2
5 | hi-ml-azure==0.2.1
6 | memory-tempfile==2.2.3
7 | msrest==0.6.21
8 | mypy==1.2.0
9 | pydicom==2.1.2
10 | pytest-cov==2.10.1
11 | pytest==6.0.1
12 | SimpleITK==2.0.2
13 | Werkzeug==2.1.1
14 |
--------------------------------------------------------------------------------
/source_config.py:
--------------------------------------------------------------------------------
1 | from dataclasses import dataclass, field
2 | from pathlib import Path
3 | from typing import List
4 |
5 |
6 | @dataclass
7 | class SourceConfig:
8 | """
9 | Contains all information that is required to submit a script to AzureML: Entry script, arguments,
10 | and information to set up the Python environment inside of the AzureML virtual machine.
11 | """
12 | root_folder: Path
13 | entry_script: Path
14 | script_params: List[str] = field(default_factory=list)
15 |
16 |
--------------------------------------------------------------------------------
/submit_for_inference.py:
--------------------------------------------------------------------------------
1 | # ------------------------------------------------------------------------------------------
2 | # Copyright (c) Microsoft Corporation. All rights reserved.
3 | # Licensed under the MIT License (MIT). See LICENSE in the repo root for license information.
4 | # ------------------------------------------------------------------------------------------
5 |
6 | import logging
7 | import os
8 | import shutil
9 | import tempfile
10 | import uuid
11 | from pathlib import Path
12 | from typing import Tuple
13 |
14 | from attr import dataclass
15 | from azureml.core import Experiment, Model, ScriptRunConfig, Environment, Datastore
16 | from azureml.core.runconfig import RunConfiguration
17 | from azureml.core.workspace import WORKSPACE_DEFAULT_BLOB_STORE_NAME, Workspace
18 |
19 | from azure_config import AzureConfig
20 | from source_config import SourceConfig
21 |
22 | ENVIRONMENT_VERSION = "1"
23 | DEFAULT_RESULT_IMAGE_NAME = "segmentation.dcm.zip"
24 | DEFAULT_DATA_FOLDER = "data"
25 | SCORE_SCRIPT = "score.py"
26 | RUN_SCORING_SCRIPT = "download_model_and_run_scoring.py"
27 | # The property in the model registry that holds the name of the Python environment
28 | PYTHON_ENVIRONMENT_NAME = "python_environment_name"
29 | IMAGEDATA_FILE_NAME = "imagedata.zip"
30 |
31 |
32 | @dataclass
33 | class SubmitForInferenceConfig:
34 | """
35 | Inference config class.
36 | """
37 | model_id: str
38 | image_data: bytes
39 | experiment_name: str
40 |
41 |
42 | def create_run_config(azure_config: AzureConfig,
43 | source_config: SourceConfig,
44 | environment_name: str) -> ScriptRunConfig:
45 | """
46 | Creates a configuration to run the InnerEye training script in AzureML.
47 | :param azure_config: azure related configurations to use for model scale-out behaviour
48 | :param source_config: configurations for model execution, such as name and execution mode
49 | :param environment_name: If specified, try to retrieve the existing Python environment with this name. If that
50 | is not found, create one from the Conda files provided in `source_config`. This parameter is meant to be used
51 | when running inference for an existing model.
52 | :return: The configured script run.
53 | """
54 | # AzureML seems to sometimes expect the entry script path in Linux format, hence convert to posix path
55 | entry_script_relative_path = source_config.entry_script.relative_to(source_config.root_folder).as_posix()
56 | logging.info(f"Entry script {entry_script_relative_path} ({source_config.entry_script} "
57 | f"relative to source directory {source_config.root_folder})")
58 | max_run_duration = 43200 # 12 hours in seconds
59 | workspace = azure_config.get_workspace()
60 | run_config = RunConfiguration(script=entry_script_relative_path, arguments=source_config.script_params)
61 | env = Environment.get(azure_config.get_workspace(), name=environment_name, version=ENVIRONMENT_VERSION)
62 | logging.info(f"Using existing Python environment '{env.name}'.")
63 | run_config.environment = env
64 | run_config.target = azure_config.cluster
65 | run_config.max_run_duration_seconds = max_run_duration
66 | # Use blob storage for storing the source, rather than the FileShares section of the storage account.
67 | run_config.source_directory_data_store = workspace.datastores.get(WORKSPACE_DEFAULT_BLOB_STORE_NAME).name
68 | script_run_config = ScriptRunConfig(source_directory=str(source_config.root_folder), run_config=run_config)
69 | return script_run_config
70 |
71 |
72 | def submit_for_inference(args: SubmitForInferenceConfig, workspace: Workspace, azure_config: AzureConfig) -> Tuple[str, str]:
73 | """
74 | Create and submit an inference to AzureML, and optionally download the resulting segmentation.
75 | :param args: configuration, see SubmitForInferenceConfig
76 | :param workspace: Azure ML workspace.
77 | :param azure_config: An object with all necessary information for accessing Azure.
78 | :return: Azure Run Id (and the target path on the datastore, including the uuid, for a unit
79 | test to ensure that the image data zip is overwritten after infernece)
80 | """
81 | logging.info("Identifying model")
82 | model = Model(workspace=workspace, id=args.model_id)
83 | model_id = model.id
84 | logging.info(f"Identified model {model_id}")
85 |
86 | source_directory = tempfile.TemporaryDirectory()
87 | source_directory_path = Path(source_directory.name)
88 | logging.info(f"Building inference run submission in {source_directory_path}")
89 |
90 | image_folder = source_directory_path / DEFAULT_DATA_FOLDER
91 | image_folder.mkdir(parents=True, exist_ok=True)
92 | image_path = image_folder / IMAGEDATA_FILE_NAME
93 | image_path.write_bytes(args.image_data)
94 |
95 | image_datastore = Datastore(workspace, azure_config.datastore_name)
96 | target_path = f"{azure_config.image_data_folder}/{str(uuid.uuid4())}"
97 | image_datastore.upload_files(files=[str(image_path)], target_path=target_path, overwrite=False, show_progress=False)
98 | image_path.unlink()
99 |
100 | # Retrieve the name of the Python environment that the training run used. This environment
101 | # should have been registered. If no such environment exists, it will be re-create from the
102 | # Conda files provided.
103 | python_environment_name = model.tags.get(PYTHON_ENVIRONMENT_NAME, "")
104 | if python_environment_name == "":
105 | raise ValueError(
106 | f"Model ID: {model_id} does not contain an environment tag {PYTHON_ENVIRONMENT_NAME}")
107 |
108 | # Copy the scoring script from the repository. This will start the model download from Azure,
109 | # and invoke the scoring script.
110 | entry_script = source_directory_path / Path(RUN_SCORING_SCRIPT).name
111 | current_file_path = Path(os.path.dirname(os.path.realpath(__file__)))
112 | shutil.copyfile(current_file_path / str(RUN_SCORING_SCRIPT), str(entry_script))
113 | source_config = SourceConfig(
114 | root_folder=source_directory_path,
115 | entry_script=entry_script,
116 | script_params=["--model_id", model_id,
117 | "--script_name", SCORE_SCRIPT,
118 | "--datastore_name", azure_config.datastore_name,
119 | "--datastore_image_path", str(Path(target_path) / IMAGEDATA_FILE_NAME)])
120 | run_config = create_run_config(azure_config, source_config, environment_name=python_environment_name)
121 | exp = Experiment(workspace=workspace, name=args.experiment_name)
122 | run = exp.submit(run_config)
123 | logging.info(f"Submitted run {run.id} in experiment {run.experiment.name}")
124 | logging.info(f"Run URL: {run.get_portal_url()}")
125 | source_directory.cleanup()
126 | logging.info(f"Deleted submission directory {source_directory_path}")
127 | return run.id, target_path
128 |
--------------------------------------------------------------------------------