├── .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 | ![ash_architecture.png](https://dev.azure.com/msdsip/8520c5e0-ef36-49bc-983d-12972ea056e0/_apis/git/repositories/cecb2ded-12e0-46f2-a2fe-7bf99a94811f/Items?path=%2F.attachments%2Fash_architecture-461fa2d7-8655-4ce9-b5b9-e6572b51030f.png&download=false&resolveLfs=true&%24format=octetStream&api-version=5.0-preview.1&sanitize=true&versionDescriptor.version=wikiMaster) 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 | ![WAF setup image](./docs/../WAF_setup.png) 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 | --------------------------------------------------------------------------------