├── .gitattributes ├── .github ├── pull_request_template.md └── workflows │ ├── README.md │ ├── autoyapf.yml │ ├── conda_build_and_publish.yml │ └── docker_build_test_publish.yml ├── .gitignore ├── .style.yapf ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Documentation ├── documentation.md ├── pics │ ├── ROI.png │ ├── line.png │ ├── sliceXPick.png │ ├── viewer.pptx │ ├── windowLevel.png │ └── zoom.png ├── readme-images │ ├── StandaloneViewerEgg.PNG │ ├── WebCILViewer2D.PNG │ └── WebCILViewer3D.PNG ├── singlewidgetviewer │ └── singlewidgetviewer.md └── triwidgetviewer │ ├── images │ ├── anchor.png │ ├── broken_link.png │ ├── link.png │ ├── show.png │ └── tree_icon.png │ └── triWidgetViewer.md ├── LICENSE ├── README.md ├── Wrappers ├── Python │ ├── ccpi │ │ ├── __init__.py │ │ ├── viewer │ │ │ ├── CILViewer.py │ │ │ ├── CILViewer2D.py │ │ │ ├── CILViewerBase.py │ │ │ ├── QCILRenderWindowInteractor.py │ │ │ ├── QCILViewerWidget.py │ │ │ ├── __init__.py │ │ │ ├── cli │ │ │ │ └── resample.py │ │ │ ├── icons │ │ │ │ ├── anchor.png │ │ │ │ ├── broken_link.png │ │ │ │ ├── link.png │ │ │ │ ├── plus.png │ │ │ │ ├── show.png │ │ │ │ ├── sync.png │ │ │ │ └── tree_icon.png │ │ │ ├── iviewer.py │ │ │ ├── standalone_viewer.py │ │ │ ├── ui │ │ │ │ ├── __init__.py │ │ │ │ ├── dialogs.py │ │ │ │ ├── main_windows.py │ │ │ │ └── qt_widgets.py │ │ │ ├── undirected_graph.py │ │ │ ├── utils │ │ │ │ ├── CameraData.py │ │ │ │ ├── __init__.py │ │ │ │ ├── colormaps.py │ │ │ │ ├── conversion.py │ │ │ │ ├── error_handling.py │ │ │ │ ├── example_data.py │ │ │ │ ├── hdf5_io.py │ │ │ │ ├── io.py │ │ │ │ └── visualisation_pipeline.py │ │ │ ├── viewerLinker.py │ │ │ └── widgets │ │ │ │ ├── __init__.py │ │ │ │ ├── box_widgets.py │ │ │ │ └── slider.py │ │ └── web_viewer │ │ │ ├── README.md │ │ │ ├── __init__.py │ │ │ ├── dev-environment.yml │ │ │ ├── test │ │ │ ├── __init__.py │ │ │ ├── test_trameviewer.py │ │ │ ├── test_trameviewer2d.py │ │ │ ├── test_trameviewer3d.py │ │ │ └── test_web_app.py │ │ │ ├── trame_viewer.py │ │ │ ├── trame_viewer2D.py │ │ │ ├── trame_viewer3D.py │ │ │ └── web_app.py │ ├── conda-recipe │ │ ├── bld.bat │ │ ├── build.sh │ │ ├── environment.yml │ │ ├── meta.yaml │ │ └── ui_env.yml │ ├── examples │ │ ├── 2Dimage_reading.py │ │ ├── error_observer.py │ │ ├── image_reader_and_writer.py │ │ ├── read_and_resample.py │ │ ├── rectilinearwipe.py │ │ ├── ui_examples │ │ │ ├── BoxWidgetAroundSlice.py │ │ │ ├── FourDockableLinkedViewerWidgets.py │ │ │ ├── SingleViewerCentralWidget.py │ │ │ ├── SingleViewerCentralWidget2.py │ │ │ ├── TwoLinkedViewersCentralWidget.py │ │ │ └── opacity_in_viewer.py │ │ ├── viewer2D-without-qt.py │ │ └── viewer3D-without-qt.py │ ├── setup.py │ └── test │ │ ├── __init__.py │ │ ├── test_CILViewer3D.py │ │ ├── test_CILViewerBase.py │ │ ├── test_camera_data.py │ │ ├── test_cli_resample.py │ │ ├── test_conversion.py │ │ ├── test_cropped_readers.py │ │ ├── test_example_data.py │ │ ├── test_hdf5.py │ │ ├── test_io.py │ │ ├── test_resample_readers.py │ │ ├── test_ui_dialogs.py │ │ ├── test_version.py │ │ ├── test_viewer_main_windows.py │ │ └── test_vtk_image_resampler.py └── javascript │ └── test_vtk.html └── docker └── web-app ├── Dockerfile ├── README.md ├── clone-and-install.sh ├── create-data-folder.sh ├── entrypoint.sh ├── environment.yml └── install-mambaforge-and-env.sh /.gitattributes: -------------------------------------------------------------------------------- 1 | # Prevent windows from converting line endings: 2 | *.sh text eol=lf -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Describe your changes 2 | 3 | 4 | ## Describe any testing you have performed 5 | *Consider adding example code to [examples](https://github.com/vais-ral/CILViewer/tree/pr-template/Wrappers/Python/examples)* 6 | 7 | 8 | ## Link relevant issues 9 | 10 | 11 | ## Checklist when you are ready to request a review 12 | 13 | - [ ] I have performed a self-review of my code 14 | - [ ] I have added docstrings in line with the guidance in the [CIL developer guide](https://tomographicimaging.github.io/CIL/nightly/developer_guide.html) 15 | - [ ] I have implemented unit tests that cover any new or modified functionality 16 | - [ ] CHANGELOG.md has been updated with any functionality change 17 | - [ ] Request review from all relevant developers 18 | - [ ] Change pull request label to 'waiting for review' 19 | 20 | ## Contribution Notes 21 | - [ ] The content of this Pull Request (the Contribution) is intentionally submitted for inclusion in CILViewer (the Work) under the terms and conditions of the [Apache-2.0 License](https://spdx.org/licenses/Apache-2.0.html) 22 | - [ ] I confirm that the contribution does not violate any intellectual property rights of third parties 23 | 24 | Qt contributions should follow Qt naming conventions i.e. camelCase method names. 25 | 26 | VTK contributions should follow VTK naming conventions i.e. PascalCase method names. 27 | -------------------------------------------------------------------------------- /.github/workflows/README.md: -------------------------------------------------------------------------------- 1 | # GitHub Actions 2 | 3 | ## Building the Conda Package: [conda_build_and_publish](https://github.com/vais-ral/CILViewer/blob/master/.github/workflows/conda_build_and_publish.yml) 4 | This github action builds and tests the conda package, by using the [conda-package-publish-action](https://github.com/paskino/conda-package-publish-action) 5 | 6 | When making an [annotated](https://git-scm.com/book/en/v2/Git-Basics-Tagging) tag, the code is built, tested and published to the [ccpi conda channel for ccpi-viewer](https://anaconda.org/ccpi/ccpi-viewer/files) as noarch package, as pure python. Previously we provided packages for linux, windows and macOS versions. 7 | 8 | When pushing to master, or opening or modifying a pull request to master, a single variant is built and tested, but not published. This variant is `python=3.7` and `numpy=1.18`. Notice that we removed the variants from the recipe as we do not have a strong dependency on numpy or python. 9 | 10 | ## Auto-yapf: [autoyapf](https://github.com/vais-ral/CILViewer/blob/master/.github/workflows/autoyapf.yml) 11 | This action runs yapf auto-formatting on the whole repository, it runs on both push to master, and push to pull requests. Once it has ran yapf, it will commit to the branch it has checked out for the action, so on master, and the pull request branch when running on a pull request. 12 | 13 | It works by using mritunjaysharma394's autoyapf github action, to perform the formatting then manually committing to the branch that is checked out, but only after checking if there are any changes to files in the git tree. 14 | -------------------------------------------------------------------------------- /.github/workflows/autoyapf.yml: -------------------------------------------------------------------------------- 1 | name: Yapf-ify this PR 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - 'main' 7 | - 'master' 8 | push: 9 | branches: 10 | - 'main' 11 | - 'master' 12 | - 'releases/**' 13 | 14 | jobs: 15 | autoyapf: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - if: github.event_name != 'pull_request' 19 | uses: actions/checkout@v3 20 | - if: github.event_name == 'pull_request' 21 | uses: actions/checkout@v3 22 | with: 23 | repository: ${{ github.event.pull_request.head.repo.full_name }} 24 | ref: ${{ github.event.pull_request.head.ref }} 25 | 26 | - name: autoyapf 27 | id: autoyapf 28 | uses: mritunjaysharma394/autoyapf@v2 29 | with: 30 | args: --parallel --recursive --in-place . 31 | 32 | - name: Check for modified files 33 | id: git-check 34 | run: echo ::set-output name=modified::$(if git diff-index --quiet HEAD --; then echo "false"; else echo "true"; fi) 35 | 36 | - name: Push changes 37 | if: steps.git-check.outputs.modified == 'true' 38 | continue-on-error: true 39 | run: | 40 | git config --global user.name 'github-actions' 41 | git config --global user.email 'github-actions@github.com' 42 | git commit -am "Automated autoyapf fixes" 43 | git push -------------------------------------------------------------------------------- /.github/workflows/conda_build_and_publish.yml: -------------------------------------------------------------------------------- 1 | name: publish_conda 2 | 3 | on: 4 | release: 5 | types: [published] 6 | push: 7 | branches: [ master ] 8 | tags: 9 | - '**' 10 | pull_request: 11 | branches: [ master ] 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-22.04 16 | steps: 17 | - uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 # All history for use later with the meta.yaml file for conda-recipes 20 | - name: publish-to-conda 21 | uses: TomographicImaging/conda-package-publish-action@v2 22 | with: 23 | subDir: 'Wrappers/Python/conda-recipe' 24 | channels: '-c conda-forge -c ccpi' 25 | AnacondaToken: ${{ secrets.ANACONDA_TOKEN }} 26 | publish: ${{ github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags') }} 27 | test_all: ${{(github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags')) || (github.ref == 'refs/heads/master')}} 28 | additional_apt_packages: 'libegl1-mesa libegl1-mesa-dev' 29 | 30 | -------------------------------------------------------------------------------- /.github/workflows/docker_build_test_publish.yml: -------------------------------------------------------------------------------- 1 | name: Docker build test publish 2 | 3 | on: 4 | pull_request: 5 | branches: [ master ] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-22.04 10 | steps: 11 | - uses: actions/checkout@v3 12 | 13 | - name: Install docker and pre-reqs 14 | shell: bash -l {0} 15 | run: | 16 | sudo apt-get update -y 17 | sudo apt-get upgrade -y 18 | sudo apt-get install ca-certificates curl gnupg lsb-release -y 19 | sudo mkdir -p /etc/apt/keyrings 20 | curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg 21 | echo \ 22 | "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \ 23 | $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null 24 | sudo apt-get update 25 | sudo apt-get install docker-ce docker-ce-cli containerd.io docker-compose-plugin 26 | sudo docker run hello-world 27 | 28 | - name: Build docker container 29 | shell: bash -l {0} 30 | run: | 31 | docker build -t cil-viewer docker/web-app 32 | 33 | - name: Run docker container with tests 34 | shell: bash -l {0} 35 | run: | 36 | docker run --rm --entrypoint /bin/bash -v /home/runner/work/CILViewer/CILViewer:/root/source_code cil-viewer -c "source ./mambaforge/etc/profile.d/conda.sh && conda activate cilviewer_webapp && conda install cil-data pytest pyside2 eqt>=1.0.0 -c ccpi && python -m pytest /root/source_code/Wrappers/Python -k 'not test_version and not test_cli_resample and not test_CILViewerBase and not test_CILViewer3D and not test_viewer_main_windows and not test_ui_dialogs'" 37 | # TODO: publish to come later 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ccpi_viewer.egg-info 2 | *.pyc 3 | __pycache__ 4 | *.log 5 | 6 | Wrappers/Python/ccpi/viewer/version.py 7 | Wrappers/Python/ccpi/web_viewer/data/* 8 | 9 | # Hide PyCharm or other Jetbrains IDE files 10 | .idea 11 | 12 | Wrappers/Python/build/* 13 | 14 | 15 | -------------------------------------------------------------------------------- /.style.yapf: -------------------------------------------------------------------------------- 1 | [style] 2 | based_on_style = pep8 3 | column_limit = 120 4 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Developer Contribution Guide 2 | Contribute to the repository by opening a pull request. 3 | 4 | ## Local 5 | Clone the source code. 6 | ```sh 7 | git clone git@github.com:TomographicImaging/CILViewer.git 8 | ``` 9 | Navigate the folder. 10 | ```sh 11 | cd CILViewer 12 | ``` 13 | 14 | Create a new environment. 15 | ```sh 16 | conda env create –f Wrappers/Python/conda-recipe/environment.yml 17 | ``` 18 | Activate the environment. 19 | ```sh 20 | conda activate cilviewer 21 | ``` 22 | Install the package. 23 | ```sh 24 | pip install ./Wrappers/Python --no-dependencies 25 | ``` 26 | 27 | ### Run tests 28 | Before merging a pull request, all tests must pass. 29 | Install the required packages: 30 | ```sh 31 | conda install eqt pillow pyside2 pytest -c ccpi cil-data=22.0.0 32 | ``` 33 | Tests can be run locally from the repository folder 34 | ```sh 35 | python -m pytest Wrappers/Python/test 36 | ``` 37 | 38 | ## Continuous integration 39 | 40 | ### Changelog 41 | Located in [CHANGELOG.md](./CHANGELOG.md). 42 | 43 | ##### Changelog style 44 | The changelog file needs to be updated manually every time a pull request (PR) is submitted. 45 | - Itemise the message with "-". 46 | - Be concise by explaining the overall changes in only a few words. 47 | - Mention the relevant PR. 48 | 49 | ###### Example: 50 | - Add CONTRIBUTING.md #403 51 | -------------------------------------------------------------------------------- /Documentation/documentation.md: -------------------------------------------------------------------------------- 1 | # CILViewer Documentation 2 | 3 | ## Using the 2D and 3D Viewers keyboard interactors 4 | 5 | ### **2D Viewer keybindings** 6 | The interactive viewer CILViewer2D provides: 7 | - Keyboard Interactions: 8 | - 'h' display the help 9 | - 'x' slices on the YZ plane 10 | - 'y' slices on the XZ plane 11 | - 'z' slices on the XY 12 | - 'a' auto window/level to accomodate all values 13 | - 's' save render to PNG (current_render.png) 14 | - 'l' plots horizontal and vertical profiles of the displayed image at the pointer location 15 | - 'i' toggles interpolation 16 | - slice up/down: mouse scroll (10 x pressing SHIFT) 17 | - Window/Level: ALT + Right Mouse Button + drag 18 | - Pan: CTRL + Right Mouse Button + drag 19 | - Zoom: SHIFT + Right Mouse Button + drag (up: zoom in, down: zoom out) 20 | - Pick: Left Mouse Click 21 | - ROI (square): 22 | - Create ROI: CTRL + Left Mouse Button 23 | - Resize ROI: Left Mouse Button on outline + drag 24 | - Translate ROI: Middle Mouse Button within ROI 25 | - Delete ROI: ALT + Left Mouse Button 26 | 27 | ### Demonstration on head dataset[1] 28 | 29 | | 2D viewer | Zoom | Slice X + Pick | 30 | |----- |--- |--- | 31 | |![Window/Level](pics/windowLevel.png)|![Zoom](pics/zoom.png)|![Slice X + Pick](pics/sliceXPick.png)| 32 | 33 | | ROI | Line profiles | 34 | |--- |--- | 35 | |![ROI](pics/ROI.png)|![line](pics/line.png)| 36 | 37 | ### **3D Viewer keybindings** 38 | The interactive 3D viewer CILViewer provides: 39 | - Keyboard Interactions: 40 | - 'h' display the help 41 | - 'x' slices on the YZ plane 42 | - 'y' slices on the XZ plane 43 | - 'z' slices on the XY 44 | - 'r' save render to current_render.png 45 | - 's' toggle visibility of slice 46 | - 'v' toggle visibility of volume render 47 | - 'c' activates volume render clipping plane widget, for slicing through a volume. 48 | - 'a' whole image Auto Window/Level on the slice. 49 | - 'i' interpolation of the slice. 50 | - Slice: Mouse Scroll 51 | - Zoom: Right Mouse + Move Up/Down 52 | - Pan: Middle Mouse Button + Move or Shift + Left Mouse + Move 53 | - Adjust Camera: Left Mouse + Move 54 | - Rotate: Ctrl + Left Mouse + Move 55 | 56 | ## References 57 | 58 | [1] The head dataset is avaiable in [CIL-Data as 'head.mha'](https://github.com/TomographicImaging/CIL-Data) along with its license. -------------------------------------------------------------------------------- /Documentation/pics/ROI.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TomographicImaging/CILViewer/65c59499e25fc3a6131bae78201e83c097e0ce2b/Documentation/pics/ROI.png -------------------------------------------------------------------------------- /Documentation/pics/line.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TomographicImaging/CILViewer/65c59499e25fc3a6131bae78201e83c097e0ce2b/Documentation/pics/line.png -------------------------------------------------------------------------------- /Documentation/pics/sliceXPick.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TomographicImaging/CILViewer/65c59499e25fc3a6131bae78201e83c097e0ce2b/Documentation/pics/sliceXPick.png -------------------------------------------------------------------------------- /Documentation/pics/viewer.pptx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TomographicImaging/CILViewer/65c59499e25fc3a6131bae78201e83c097e0ce2b/Documentation/pics/viewer.pptx -------------------------------------------------------------------------------- /Documentation/pics/windowLevel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TomographicImaging/CILViewer/65c59499e25fc3a6131bae78201e83c097e0ce2b/Documentation/pics/windowLevel.png -------------------------------------------------------------------------------- /Documentation/pics/zoom.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TomographicImaging/CILViewer/65c59499e25fc3a6131bae78201e83c097e0ce2b/Documentation/pics/zoom.png -------------------------------------------------------------------------------- /Documentation/readme-images/StandaloneViewerEgg.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TomographicImaging/CILViewer/65c59499e25fc3a6131bae78201e83c097e0ce2b/Documentation/readme-images/StandaloneViewerEgg.PNG -------------------------------------------------------------------------------- /Documentation/readme-images/WebCILViewer2D.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TomographicImaging/CILViewer/65c59499e25fc3a6131bae78201e83c097e0ce2b/Documentation/readme-images/WebCILViewer2D.PNG -------------------------------------------------------------------------------- /Documentation/readme-images/WebCILViewer3D.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TomographicImaging/CILViewer/65c59499e25fc3a6131bae78201e83c097e0ce2b/Documentation/readme-images/WebCILViewer3D.PNG -------------------------------------------------------------------------------- /Documentation/singlewidgetviewer/singlewidgetviewer.md: -------------------------------------------------------------------------------- 1 | # 2D Slice Viewer 2 | 3 | The 2D slice viewer was designed to quickly visualise and interact with large 3D datasets. There are a number of interactions 4 | which will allow the user to slice through the image on 3 axes, plot a histogram and profile horizontal and vertical cross sections of the image. 5 | 6 | ## Opening a file 7 | Click the folder icon in the toolbar and select the file to load. This application can load single files or multiple files in tiff format. 8 | 9 | ## Saving the current image in the 2D slicer 10 | Click the save icon in the toolbar and pick a destination. 11 | 12 | ## Interactions 13 | Mouse and keyboard interaction for the 2D Slicer. 14 | 15 | ### 2D Slicer 16 | #### Keyboard Interactions 17 | * x - Slices on the YZ plane 18 | * y - Slices on the XZ plane 19 | * z - Slices on the XY plane 20 | * a - Auto window/level to accomodate all values 21 | * l - Plots horizontal and vertical profiles of the displayed image at the pointer location 22 | 23 | #### Mouse Interactions 24 | * Slice Up/Down - Scroll (x10 speed pressing SHIFT) 25 | * Window/Level - ALT + Right Mouse Button + Drag 26 | * Pan - CTRL + Right Mouse Button + Drag 27 | * Zoom - SHIFT + Right Mouse Buttin + Drag (up: zoom in, down: zoom out) 28 | * Pick - Left Mouse Click 29 | * Region of interest 30 | * Create ROI - CTRL + Left Mouse Button 31 | * Resize ROI - Click + Drag nodes 32 | * Translate ROI - Middle Mouse Button within ROI + Drag 33 | * Delete ROI - ALT + Left Mouse Button 34 | -------------------------------------------------------------------------------- /Documentation/triwidgetviewer/images/anchor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TomographicImaging/CILViewer/65c59499e25fc3a6131bae78201e83c097e0ce2b/Documentation/triwidgetviewer/images/anchor.png -------------------------------------------------------------------------------- /Documentation/triwidgetviewer/images/broken_link.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TomographicImaging/CILViewer/65c59499e25fc3a6131bae78201e83c097e0ce2b/Documentation/triwidgetviewer/images/broken_link.png -------------------------------------------------------------------------------- /Documentation/triwidgetviewer/images/link.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TomographicImaging/CILViewer/65c59499e25fc3a6131bae78201e83c097e0ce2b/Documentation/triwidgetviewer/images/link.png -------------------------------------------------------------------------------- /Documentation/triwidgetviewer/images/show.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TomographicImaging/CILViewer/65c59499e25fc3a6131bae78201e83c097e0ce2b/Documentation/triwidgetviewer/images/show.png -------------------------------------------------------------------------------- /Documentation/triwidgetviewer/images/tree_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TomographicImaging/CILViewer/65c59499e25fc3a6131bae78201e83c097e0ce2b/Documentation/triwidgetviewer/images/tree_icon.png -------------------------------------------------------------------------------- /Documentation/triwidgetviewer/triWidgetViewer.md: -------------------------------------------------------------------------------- 1 | # Tri Widget Viewer 2 | 3 | The tri widget viewer was designed to display 3 different views of the data simultaneously. 4 | The main element of the viewer is a 2D slicer. Two sub-windows display a 3D slicer and graph. 5 | Generating the graph will also generate surfaces which are rendered in the 3D slicer. 6 | Interaction with the 2D and 3D slicers can be linked or unlinked depending on user preference. 7 | 8 | ## Opening a file 9 | Click the folder icon in the toolbar and select the file to load. This application can load single files or multiple files in tiff format. 10 | 11 | ## Saving the current image in the 2D slicer 12 | Click the save icon in the toolbar and pick a destination. 13 | 14 | ## Interactions 15 | Mouse and keyboard interaction differ for the different viewers. The 2D Slicer has the most interactions available. 16 | 17 | ### 2D Slicer 18 | #### Keyboard Interactions 19 | * x - Slices on the YZ plane 20 | * y - Slices on the XZ plane 21 | * z - Slices on the XY plane 22 | * a - Auto window/level to accomodate all values 23 | * l - Plots horizontal and vertical profiles of the displayed image at the pointer location 24 | 25 | #### Mouse Interactions 26 | * Slice Up/Down - Scroll (x10 speed pressing SHIFT) 27 | * Window/Level - ALT + Right Mouse Button + Drag 28 | * Pan - CTRL + Right Mouse Button + Drag 29 | * Zoom - SHIFT + Right Mouse Buttin + Drag (up: zoom in, down: zoom out) 30 | * Pick - Left Mouse Click 31 | * Region of interest 32 | * Create ROI - CTRL + Left Mouse Button 33 | * Resize ROI - Click + Drag nodes 34 | * Translate ROI - Middle Mouse Button within ROI + Drag 35 | * Delete ROI - ALT + Left Mouse Button 36 | 37 | ### 3D Slicer 38 | #### Keyboard Interactions 39 | * x - Slices on the YZ plane 40 | * y - Slices on the XZ plane 41 | * z - Slices on the XY plane 42 | 43 | #### Mouse Interactions 44 | * Adjust Camera - Left Mouse Button + Drag 45 | * Pan - SHIFT + Left Mouse Button + Drag 46 | * Rotate - CTRL + Left Mouse Button + Drag 47 | * Zoom - Right Mouse Button + Drag (up: zoom in, down: zoom out) 48 | 49 | 50 | ### Graph 51 | #### Mouse Interaction 52 | * Zoom - Scroll 53 | * Pan - ALT + Left Mouse Button + Drag 54 | * Select Region - Left Mouse Button + Drag 55 | * Pick - Left Mouse Button 56 | 57 | 58 | ## Generating the graph 59 | The graph can be generated in the graph tools panel. This is opened using the graph icon. ![alt text][graph icon] 60 | 61 | Click "Generate Graph" to generate the graph and 3D surfaces. One the graph has been generated, modify the parameters and click "Update" to push the changes to the viewers. 62 | 63 | ### Parameters 64 | 65 | * Iso Value - As a percentage of the pixel value range, what iso value to calculate the tree and surfaces from. 66 | * Global Iso - If unchecked, will use local iso-value 67 | * Colour Surfaces - Colour individual elements of the rendered object 68 | * Log Tree Size - 69 | * Collapse Priority - 70 | 71 | ## Linking the viewers 72 | 73 | Interactions on the 2D and 3D viewers can be linked. 74 | The status of the link is shown by the icon on the button in the top left of the 2D and 3D viewers. Toggling the state is achieved by clicking this button. 75 | When the icon is a closed link ![alt text][linked icon] interactions will be passed from one viewer to the other. 76 | When the icon is an open link ![alt text][unlinked icon] interactions will not be passed between the viewers. 77 | 78 | If slice actions are performed while the viewers are unlinked, they will become out of sync. If this is undesireable, scroll until both viewers reach the extent 79 | of the current slicing direction and then the slice will be syncronised again. 80 | 81 | If window/level actions are performed while the viewer are unlinked, they will become out of sync. If this is undesireable, link and perform a window/level change 82 | action. The levels will synchronise again. 83 | 84 | #### Interactions available from both viewers 85 | These actions will be triggered by performing the action in either the 2D or 3D viewer. 86 | 87 | * Slice - Change the slice 88 | * x - Slice in the YZ plane 89 | * y - Slice in te XZ plane 90 | * z - slice in the XY plane 91 | 92 | #### Interactions only available in 2D viewer 93 | These actions will only be triggered by performing the action in the 2D viewer. 94 | 95 | * Window/Level - Adjust the window/level for the image 96 | 97 | ## Docking/Showing the Graph and 3D slicer 98 | 99 | The 3D slicer and graph widgets can be removed from the main window and resized in order to get a better view of certain elements. 100 | If you have closed either of the windows, they can be brought back by clicking the show icon ![alt text][show icon] 101 | 102 | [graph icon]: images/tree_icon.png "This icon opens the graph tool panel" 103 | [linked icon]: images/link.png "This icon shows the viewers are linked" 104 | [unlinked icon]: images/broken_link.png "This icon shows the viewers are un-linked" 105 | [anchor icon]: images/anchor.png "This icon re-docks floating widgets and shows closed widgets" 106 | [show icon]: ./images.show.png "This icon brings closed windows back" -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | | Master | Development | Anaconda binaries | 2 | |--------|-------------|-------------------| 3 | | [![Build Status](https://anvil.softeng-support.ac.uk/jenkins/buildStatus/icon?job=CILsingle/CCPi-Viewer)](https://anvil.softeng-support.ac.uk/jenkins/job/CILsingle/job/CCPi-Viewer/) | [![Build Status](https://anvil.softeng-support.ac.uk/jenkins/buildStatus/icon?job=CILsingle/CCPi-Viewer-dev)](https://anvil.softeng-support.ac.uk/jenkins/job/CILsingle/job/CCPi-Viewer-dev/) |![conda version](https://anaconda.org/ccpi/ccpi-viewer/badges/version.svg) ![conda last release](https://anaconda.org/ccpi/ccpi-viewer/badges/latest_release_date.svg) [![conda platforms](https://anaconda.org/ccpi/ccpi-viewer/badges/platforms.svg) ![conda downloads](https://anaconda.org/ccpi/ccpi-viewer/badges/downloads.svg)](https://anaconda.org/ccpi/ccpi-viewer) | 4 | 5 | # CILViewer 6 | A simple interactive viewer based on VTK classes and written in Python. 7 | - The classes in [`viewer`](Wrappers/Python/ccpi/viewer/) define generic viewers that can be embedded in [Qt](https://www.qt.io/) or other user interfaces. [`CILviewer2D`](Wrappers/Python/ccpi/viewer/CILViewer2D.py) is a 2D viewer and [`CILviewer`](Wrappers/Python/ccpi/viewer/CILViewer.py) is a 3D viewer. 8 | - The classes in [`web viewer`](Wrappers/Python/ccpi/web_viewer/) define a viewer embedded in [trame](https://kitware.github.io/trame/). 9 | 10 | Examples of QApplications are the [`iviewer`](Wrappers/Python/ccpi/viewer/iviewer.py) and the [`standalone viewer`](Wrappers/Python/ccpi/viewer/standalone_viewer.py). An example of use in an external software is [iDVC](https://github.com/TomographicImaging/iDVC). 11 | 12 | ## Installation instructions 13 | To install via `conda`, create a minimal environment using: 14 | 15 | ```bash 16 | conda create --name cilviewer ccpi-viewer=24.0.1 -c ccpi -c conda-forge 17 | ``` 18 | ### UI requirements 19 | To use the extra [UI utilities](Wrappers/Python/ccpi/viewer/ui) the environment needs to be updated to include the extra requirements `eqt` and `pyside2`. The [UI examples](Wrappers/Python/examples/ui_examples) require `cil-data` as well. The environment can be updated to include these dependencies as follows: 20 | ```sh 21 | conda env update --name cilviewer --file Wrappers/Python/conda-recipe/ui_env.yml 22 | ``` 23 | 24 | ## Run the standalone viewer QApplication 25 | 26 | - Activate your environment using: ``conda activate cilviewer``. 27 | - Launch by typing: `cilviewer` 28 | - Load a dataset using the File menu. Currently supported data formats: 29 | - HDF5, including Nexus 30 | - Raw 31 | - Tiff 32 | - Numpy 33 | - Metaimage (mha and mhd) 34 | 35 | Your image title 36 | 37 | Data shown is [1]. 38 | 39 | ## Install and run the trame web viewer 40 | Follow the [instructions](https://github.com/vais-ral/CILViewer/tree/master/Wrappers/Python/ccpi/web_viewer) to install and run the web viewer. 41 | 42 | Your image title 43 | 44 | Data shown is [2]. 45 | 46 | ## Documentation 47 | More information on how to use the viewer can be found in [Documentation.md](./Documentation/documentation.md). 48 | 49 | ## Developer Contribution Guide 50 | We welcome contributions. Please see [CONTRIBUTING.md](./CONTRIBUTING.md) for guidance. 51 | 52 | ## Notice 53 | The CIL Viewer code took initial inspiration from a previous project of Edoardo Pasca and Lukas Batteau [PyVE](https://sourceforge.net/p/pyve/code/ci/master/tree/PyVE/), the license of which we report here: 54 | 55 | ``` 56 | Copyright (c) 2012, Edoardo Pasca and Lukas Batteau 57 | All rights reserved. 58 | 59 | Redistribution and use in source and binary forms, with or without modification, 60 | are permitted provided that the following conditions are met: 61 | 62 | * Redistributions of source code must retain the above copyright notice, this list 63 | of conditions and the following disclaimer. 64 | 65 | * Redistributions in binary form must reproduce the above copyright notice, this 66 | list of conditions and the following disclaimer in the documentation and/or other 67 | materials provided with the distribution. 68 | 69 | * Neither the name of Edoardo Pasca or Lukas Batteau nor the names of any 70 | contributors may be used to endorse or promote products derived from this 71 | software without specific prior written permission. 72 | 73 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY 74 | EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES 75 | OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT 76 | SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 77 | INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED 78 | TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR 79 | BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 80 | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN 81 | ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 82 | ``` 83 | 84 | ## References 85 | [1] The chocolate egg dataset shown in above examples is dataset `egg2`: 86 | 87 | Jakob Sauer Jørgensen, Martin Skovgaard Andersen, & Carsten Gundlach. (2021). HDTomo TXRM micro-CT datasets [Data set]. Zenodo. https://doi.org/10.5281/zenodo.4822516 88 | 89 | [2] The head dataset is avaiable in [CIL-Data as 'head.mha'](https://github.com/TomographicImaging/CIL-Data) along with its license. 90 | 91 | -------------------------------------------------------------------------------- /Wrappers/Python/ccpi/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TomographicImaging/CILViewer/65c59499e25fc3a6131bae78201e83c097e0ce2b/Wrappers/Python/ccpi/__init__.py -------------------------------------------------------------------------------- /Wrappers/Python/ccpi/viewer/QCILRenderWindowInteractor.py: -------------------------------------------------------------------------------- 1 | import vtk 2 | from vtk.qt.QVTKRenderWindowInteractor import QVTKRenderWindowInteractor 3 | from PySide2.QtCore import Qt, QEvent 4 | 5 | 6 | class QCILRenderWindowInteractor(QVTKRenderWindowInteractor): 7 | ''' 8 | A QVTKRenderWindowInteractor for Python and Qt. Uses a vtkGenericRenderWindowInteractor to handle the interactions. 9 | Use GetRenderWindow() to get the vtkRenderWindow. Create with the keyword stereo=1 in order to generate 10 | a stereo-capable window. Extends the QVTKRenderWindowInteractor to accept also ALT modifier. 11 | 12 | More info: https://docs.vtk.org/en/latest/api/python/vtkmodules/vtkmodules.qt.QVTKRenderWindowInteractor.html 13 | ''' 14 | 15 | def __init__(self, parent=None, **kw): 16 | '''Constructor''' 17 | super(QCILRenderWindowInteractor, self).__init__(parent, **kw) 18 | self.__saveModifiers = self._QVTKRenderWindowInteractor__saveModifiers 19 | self.__saveX = self._QVTKRenderWindowInteractor__saveX 20 | self.__saveY = self._QVTKRenderWindowInteractor__saveY 21 | self.__saveButtons = self._QVTKRenderWindowInteractor__saveButtons 22 | self.__wheelDelta = self._QVTKRenderWindowInteractor__wheelDelta 23 | #print ("__saveModifiers should be defined", self.__saveModifiers) 24 | 25 | def _GetCtrlShiftAlt(self, ev): 26 | '''Get CTRL SHIFT ALT key modifiers''' 27 | ctrl = shift = alt = False 28 | 29 | if hasattr(ev, 'modifiers'): 30 | 31 | if ev.modifiers() & Qt.ShiftModifier: 32 | shift = True 33 | if ev.modifiers() & Qt.ControlModifier: 34 | ctrl = True 35 | if ev.modifiers() & Qt.AltModifier: 36 | alt = True 37 | else: 38 | if self.__saveModifiers & Qt.ShiftModifier: 39 | shift = True 40 | if self.__saveModifiers & Qt.ControlModifier: 41 | ctrl = True 42 | if self.__saveModifiers & Qt.AltModifier: 43 | alt = True 44 | 45 | return ctrl, shift, alt 46 | 47 | def enterEvent(self, ev): 48 | '''Overload of enterEvent from base class to use _GetCtrlShiftAlt''' 49 | ctrl, shift, alt = self._GetCtrlShiftAlt(ev) 50 | self._Iren.SetEventInformationFlipY(self.__saveX, self.__saveY, ctrl, shift, chr(0), 0, None) 51 | self._Iren.EnterEvent() 52 | 53 | def leaveEvent(self, ev): 54 | '''Overload of leaveEvent from base class to use _GetCtrlShiftAlt''' 55 | ctrl, shift, alt = self._GetCtrlShiftAlt(ev) 56 | self._Iren.SetEventInformationFlipY(self.__saveX, self.__saveY, ctrl, shift, chr(0), 0, None) 57 | self._Iren.LeaveEvent() 58 | 59 | def mousePressEvent(self, ev): 60 | '''Overload of mousePressEvent from base class to use _GetCtrlShiftAlt''' 61 | ctrl, shift, alt = self._GetCtrlShiftAlt(ev) 62 | repeat = 0 63 | if ev.type() == QEvent.MouseButtonDblClick: 64 | repeat = 1 65 | self._Iren.SetEventInformationFlipY(ev.x(), ev.y(), ctrl, shift, chr(0), repeat, None) 66 | self._Iren.SetAltKey(alt) 67 | self._Iren.SetShiftKey(shift) 68 | self._Iren.SetControlKey(ctrl) 69 | 70 | self._ActiveButton = ev.button() 71 | 72 | if self._ActiveButton == Qt.LeftButton: 73 | self._Iren.LeftButtonPressEvent() 74 | elif self._ActiveButton == Qt.RightButton: 75 | self._Iren.RightButtonPressEvent() 76 | elif self._ActiveButton == Qt.MidButton: 77 | self._Iren.MiddleButtonPressEvent() 78 | 79 | def mouseMoveEvent(self, ev): 80 | '''Overload of mouseMoveEvent from base class to use _GetCtrlShiftAlt''' 81 | self.__saveModifiers = ev.modifiers() 82 | self.__saveButtons = ev.buttons() 83 | self.__saveX = ev.x() 84 | self.__saveY = ev.y() 85 | 86 | #ctrl, shift = self._GetCtrlShift(ev) 87 | ctrl, shift, alt = self._GetCtrlShiftAlt(ev) 88 | self._Iren.SetEventInformationFlipY(ev.x(), ev.y(), ctrl, shift, chr(0), 0, None) 89 | self._Iren.SetAltKey(alt) 90 | self._Iren.MouseMoveEvent() 91 | -------------------------------------------------------------------------------- /Wrappers/Python/ccpi/viewer/QCILViewerWidget.py: -------------------------------------------------------------------------------- 1 | import vtk 2 | import sys 3 | import vtk 4 | from PySide2 import QtCore, QtWidgets 5 | from ccpi.viewer.QCILRenderWindowInteractor import QCILRenderWindowInteractor 6 | from ccpi.viewer import viewer2D, viewer3D 7 | 8 | 9 | class QCILViewerWidget(QtWidgets.QFrame): 10 | '''A QFrame to embed in Qt application containing a VTK Render Window 11 | 12 | All the interaction is passed from Qt to VTK. 13 | 14 | :param viewer: The viewer you want to embed in Qt: CILViewer2D or CILViewer 15 | :param interactorStyle: The interactor style for the Viewer. 16 | ''' 17 | 18 | def __init__(self, 19 | parent, 20 | viewer, 21 | shape=(600, 600), 22 | debug=False, 23 | renderer=None, 24 | interactorStyle=None, 25 | enableSliderWidget=True): 26 | '''Creator. Creates an instance of a QFrame and of a CILViewer 27 | 28 | The viewer is placed in the QFrame inside a QVBoxLayout. 29 | The viewer is accessible as member 'viewer' 30 | ''' 31 | 32 | super(QCILViewerWidget, self).__init__(parent=parent) 33 | # currently the size of the frame is set by stretching to the whole 34 | # area in the main window. A resize of the MainWindow triggers a resize of 35 | # the QFrame to occupy the whole area available. 36 | 37 | dimx, dimy = shape 38 | # self.resize(dimx, dimy) 39 | 40 | self.vtkWidget = QCILRenderWindowInteractor(self) 41 | 42 | if renderer is None: 43 | self.ren = vtk.vtkRenderer() 44 | else: 45 | self.ren = renderer 46 | 47 | self.vtkWidget.GetRenderWindow().AddRenderer(self.ren) 48 | # https://discourse.vtk.org/t/qvtkwidget-render-window-is-outside-main-qt-app-window/1539/8?u=edoardo_pasca 49 | self.iren = self.vtkWidget.GetRenderWindow().GetInteractor() 50 | 51 | if viewer is viewer2D: 52 | self.viewer = viewer(dimx=dimx, 53 | dimy=dimy, 54 | ren=self.ren, 55 | renWin=self.vtkWidget.GetRenderWindow(), 56 | iren=self.iren, 57 | debug=debug, 58 | enableSliderWidget=enableSliderWidget) 59 | al = self.viewer.axisLabelsText 60 | self.viewer.setAxisLabels([al[0], al[1], ''], False) 61 | elif viewer is viewer3D: 62 | self.viewer = viewer(dimx=dimx, 63 | dimy=dimy, 64 | ren=self.ren, 65 | renWin=self.vtkWidget.GetRenderWindow(), 66 | iren=self.iren, 67 | debug=debug) 68 | else: 69 | raise KeyError("Viewer class not provided. Submit an uninstantiated viewer class object" 70 | "using 'viewer' keyword") 71 | 72 | if interactorStyle is not None: 73 | self.viewer.style = interactorStyle(self.viewer) 74 | self.viewer.iren.SetInteractorStyle(self.viewer.style) 75 | 76 | self.vl = QtWidgets.QVBoxLayout() 77 | self.vl.addWidget(self.vtkWidget) 78 | self.setLayout(self.vl) 79 | self.adjustSize() 80 | 81 | 82 | class QCILDockableWidget(QtWidgets.QDockWidget): 83 | '''Inserts a vtk viewer in a dock widget.''' 84 | 85 | def __init__(self, parent=None, viewer=viewer2D, shape=(600, 600), interactorStyle=None, title=""): 86 | '''Creates an instance of a `QCILDockableWidget` and inserts it in a `QDockWidget`. 87 | Sets the title of the dock widget.''' 88 | super(QCILDockableWidget, self).__init__(parent) 89 | 90 | self.frame = QCILViewerWidget(parent, viewer, shape=shape, interactorStyle=interactorStyle) 91 | self.viewer = self.frame.viewer 92 | 93 | self.setWindowTitle(title) 94 | 95 | self.setWidget(self.frame) 96 | -------------------------------------------------------------------------------- /Wrappers/Python/ccpi/viewer/__init__.py: -------------------------------------------------------------------------------- 1 | SLICE_ORIENTATION_XY = 2 # Z 2 | SLICE_ORIENTATION_XZ = 1 # Y 3 | SLICE_ORIENTATION_YZ = 0 # X 4 | 5 | CONTROL_KEY = 8 6 | SHIFT_KEY = 4 7 | ALT_KEY = -128 8 | 9 | SLICE_ACTOR = 'slice_actor' 10 | OVERLAY_ACTOR = 'overlay_actor' 11 | HISTOGRAM_ACTOR = 'histogram_actor' 12 | HELP_ACTOR = 'help_actor' 13 | CURSOR_ACTOR = 'cursor_actor' 14 | CROSSHAIR_ACTOR = 'crosshair_actor' 15 | LINEPLOT_ACTOR = 'lineplot_actor' 16 | WIPE_ACTOR = 'wipe_actor' 17 | 18 | from .CILViewer import CILViewer as viewer3D 19 | from .CILViewer2D import CILViewer2D as viewer2D 20 | from .CILViewer import CILInteractorStyle as istyle3D 21 | from .CILViewer2D import CILInteractorStyle as istyle2D 22 | -------------------------------------------------------------------------------- /Wrappers/Python/ccpi/viewer/cli/resample.py: -------------------------------------------------------------------------------- 1 | from argparse import ArgumentParser 2 | 3 | import yaml 4 | import schema 5 | from schema import SchemaError, Schema, Optional 6 | 7 | from ccpi.viewer.utils.io import ImageReader, ImageWriter 8 | ''' 9 | This command line tool takes a dataset file and a yaml file as input. 10 | It resamples or crops the dataset as it reads it in, and then writes 11 | out the resulting dataset to a file. 12 | 13 | Supported file types for reading: 14 | hdf5, nxs, mha, raw, numpy 15 | 16 | Supported file types for writing: 17 | hdf5, nxs, mha 18 | 19 | ''' 20 | 21 | EXAMPLE_YAML_FILE = ''' 22 | input: 23 | file_name: '24737_fd_normalised.nxs' 24 | shape: (1024,1024,1024) 25 | is_fortran: False 26 | is_big_endian: True 27 | typecode: 'float32' 28 | dataset_name: '/entry1/tomo_entry/data/data' # valid for HDF5 and Zarr 29 | resample: 30 | target_size: 1 31 | resample_z: True 32 | output: 33 | file_name: 'this_fname.nxs' 34 | format: 'hdf5' # npy, METAImage, NIFTI (or Zarr to come) 35 | ''' 36 | ''' 37 | 38 | THIS IS WHAT THE OUTPUT STRUCTURE OF AN EXAMPLE OUTPUT HDF5 FILE WOULD LOOK LIKE: 39 | Entry 1 contains attributes of the original dataset 40 | Entry 2 contains the resampled dataset and attributes 41 | 42 | - entry1 : 43 | - tomo_entry : 44 | - data : 45 | - data : 46 | - bit_depth : 64 47 | - file_name : test_3D_data.npy 48 | - header_length : 128 49 | - is_big_endian : False 50 | - origin : [0. 0. 0.] 51 | - shape : [500 100 600] 52 | - spacing : [1 1 1] 53 | - resampled: False 54 | - cropped: False 55 | - entry2 : 56 | - tomo_entry : 57 | - data : 58 | - data : 59 | - cropped : False 60 | - origin : [2.23861279 2.23861279 0. ] 61 | - resample_z : True 62 | - resampled : True 63 | - spacing : [5.47722558 5.47722558 1. ] 64 | - original_dataset: /entry1/tomo_entry/data/data 65 | ''' 66 | 67 | # This validates the input yaml file: 68 | schema = Schema({ 69 | 'input': { 70 | 'file_name': str, 71 | Optional('shape'): tuple, # only for raw 72 | Optional('is_fortran'): bool, # only for raw 73 | Optional('is_big_endian'): bool, # only for raw 74 | Optional('typecode'): str, # only for raw 75 | Optional('dataset_name'): str 76 | }, # only for hdf5 # need to set default 77 | 'resample': { 78 | 'target_size': float, 79 | 'resample_z': bool 80 | }, 81 | 'output': { 82 | 'file_name': str, 83 | 'format': str 84 | } 85 | }) 86 | 87 | 88 | def parse_arguments(): 89 | parser = ArgumentParser(prog='resampler', 90 | description='Resamples a dataset file & writes out to a file.' + 91 | ' Either specify a yaml file or set at minimum: -i, -o and -target_size') 92 | 93 | parser.add_argument( 94 | '-f', 95 | help='Input yaml file. May be used in place of all other arguments. If set, all other arguments are ignored.', 96 | type=str) 97 | parser.add_argument('--example', help='Prints an example input yaml file.', action='store_true') 98 | 99 | parser.add_argument('-i', help='Input dataset filename. Required if -f is not set.') 100 | parser.add_argument('--dataset_name', 101 | help='Dataset name, only required if input file is HDF5/Nexus format.', 102 | type=str, 103 | default='/entry1/tomo_entry/data/data') 104 | parser.add_argument('--shape', 105 | help='Shape of input dataset file, only required if input file is raw format.', 106 | type=lambda s: [int(item) for item in s.strip('[').strip(']').split(',')]) 107 | parser.add_argument('--is_fortran', 108 | help='Whether input dataset file is fortran order, only required if input file is raw format.') 109 | parser.add_argument('--is_big_endian', 110 | help='Whether input dataset file is big endian, only required if input file is raw format.') 111 | parser.add_argument('--typecode', 112 | help='Typecode of input dataset, only required if input file is raw format.', 113 | type=str) 114 | 115 | parser.add_argument('-target_size', 116 | help='Target size to downsample dataset to, in MB. Required if -f is not set.', 117 | type=float) 118 | parser.add_argument('--resample_z', 119 | help='Whether to resample along the z axis of the dataset. Optional.', 120 | default=True) 121 | 122 | parser.add_argument('-o', help='Output filename. Required if -f is not set.') 123 | parser.add_argument('--out_format', 124 | help='File format to write downsampled data to', 125 | choices=['hdf5', 'nxs', 'mha'], 126 | type=str, 127 | default='nxs') 128 | 129 | args = parser.parse_args() 130 | 131 | return args 132 | 133 | 134 | def get_params_from_args(args): 135 | 136 | if args.f is not None: 137 | 138 | with open(args.f) as f: 139 | params_raw = yaml.safe_load(f) 140 | params = {} 141 | for key, dict in params_raw.items(): 142 | # each of the values in data_raw is a dict 143 | params[key] = {} 144 | for sub_key, value in dict.items(): 145 | try: 146 | params[key][sub_key] = eval(value) 147 | except: 148 | params[key][sub_key] = value 149 | try: 150 | schema.validate(params) 151 | except SchemaError as e: 152 | print(e) 153 | 154 | else: 155 | for a in [args.i, args.target_size, args.o]: 156 | if a is None: 157 | raise Exception("If yaml file is not set: -i, --target_size, and -o must be set") 158 | 159 | params = { 160 | 'input': { 161 | 'file_name': args.i 162 | }, 163 | 'resample': { 164 | 'target_size': args.target_size 165 | }, 166 | 'output': { 167 | 'file_name': args.o 168 | } 169 | } 170 | 171 | args_input = ['shape', 'typecode', 'dataset_name'] 172 | bool_args_input = ['is_fortran', 'is_big_endian'] 173 | 174 | for a in args_input: 175 | if eval(f"args.{a}") is not None: 176 | params['input'][a] = eval(f"args.{a}") 177 | 178 | for a in bool_args_input: 179 | if eval(f"args.{a}") is not None: 180 | params['input'][a] = eval(eval(f"args.{a}")) 181 | 182 | if args.resample_z is not None: 183 | params['resample']['resample_z'] = eval(args.resample_z) 184 | 185 | if args.out_format is not None: 186 | params['output']['format'] = args.out_format 187 | 188 | return params 189 | 190 | 191 | def main(): 192 | args = parse_arguments() 193 | # Do nothing else if we are just printing an example yaml file: 194 | if args.example: 195 | print(EXAMPLE_YAML_FILE) 196 | return 197 | params = get_params_from_args(args) 198 | 199 | raw_attrs = None 200 | dataset_name = None 201 | if 'input' in params.keys(): 202 | raw_attrs = {} 203 | for key, value in params['input'].items(): 204 | if key == 'dataset_name': 205 | dataset_name = value 206 | elif key == 'file_name': 207 | pass 208 | else: 209 | raw_attrs[key] = value 210 | 211 | target_size = params['resample']['target_size'] 212 | if target_size is not None: 213 | target_size *= 1e6 214 | 215 | reader = ImageReader(file_name=params['input']['file_name'], 216 | resample=True, 217 | target_size=target_size, 218 | resample_z=params['resample']['resample_z'], 219 | raw_image_attrs=raw_attrs, 220 | hdf5_dataset_name=dataset_name) 221 | downsampled_image = reader.Read() 222 | original_image_attrs = reader.GetOriginalImageAttrs() 223 | loaded_image_attrs = reader.GetLoadedImageAttrs() 224 | 225 | writer = ImageWriter() 226 | writer.SetFileName(params['output']['file_name']) 227 | writer.SetFileFormat(params['output']['format']) 228 | writer.SetOriginalDataset(None, original_image_attrs) 229 | writer.AddChildDataset(downsampled_image, loaded_image_attrs) 230 | writer.Write() 231 | 232 | 233 | if __name__ == '__main__': 234 | main() 235 | -------------------------------------------------------------------------------- /Wrappers/Python/ccpi/viewer/icons/anchor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TomographicImaging/CILViewer/65c59499e25fc3a6131bae78201e83c097e0ce2b/Wrappers/Python/ccpi/viewer/icons/anchor.png -------------------------------------------------------------------------------- /Wrappers/Python/ccpi/viewer/icons/broken_link.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TomographicImaging/CILViewer/65c59499e25fc3a6131bae78201e83c097e0ce2b/Wrappers/Python/ccpi/viewer/icons/broken_link.png -------------------------------------------------------------------------------- /Wrappers/Python/ccpi/viewer/icons/link.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TomographicImaging/CILViewer/65c59499e25fc3a6131bae78201e83c097e0ce2b/Wrappers/Python/ccpi/viewer/icons/link.png -------------------------------------------------------------------------------- /Wrappers/Python/ccpi/viewer/icons/plus.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TomographicImaging/CILViewer/65c59499e25fc3a6131bae78201e83c097e0ce2b/Wrappers/Python/ccpi/viewer/icons/plus.png -------------------------------------------------------------------------------- /Wrappers/Python/ccpi/viewer/icons/show.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TomographicImaging/CILViewer/65c59499e25fc3a6131bae78201e83c097e0ce2b/Wrappers/Python/ccpi/viewer/icons/show.png -------------------------------------------------------------------------------- /Wrappers/Python/ccpi/viewer/icons/sync.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TomographicImaging/CILViewer/65c59499e25fc3a6131bae78201e83c097e0ce2b/Wrappers/Python/ccpi/viewer/icons/sync.png -------------------------------------------------------------------------------- /Wrappers/Python/ccpi/viewer/icons/tree_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TomographicImaging/CILViewer/65c59499e25fc3a6131bae78201e83c097e0ce2b/Wrappers/Python/ccpi/viewer/icons/tree_icon.png -------------------------------------------------------------------------------- /Wrappers/Python/ccpi/viewer/iviewer.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import vtk 3 | from PySide2 import QtCore, QtWidgets 4 | from ccpi.viewer import viewer2D, viewer3D 5 | from ccpi.viewer.QCILViewerWidget import QCILViewerWidget 6 | import ccpi.viewer.viewerLinker as vlink 7 | from ccpi.viewer.utils.conversion import Converter 8 | import numpy as np 9 | from ccpi.viewer.utils import example_data 10 | 11 | 12 | class SingleViewerCenterWidget(QtWidgets.QMainWindow): 13 | 14 | def __init__(self, parent=None, viewer=viewer2D): 15 | QtWidgets.QMainWindow.__init__(self, parent) 16 | 17 | self.frame = QCILViewerWidget(parent, viewer=viewer, shape=(600, 600)) 18 | 19 | if viewer == viewer3D: 20 | self.frame.viewer.setVolumeRenderOpacityMethod('scalar') 21 | 22 | self.setCentralWidget(self.frame) 23 | 24 | self.show() 25 | 26 | def set_input(self, data): 27 | self.frame.viewer.setInputData(data) 28 | 29 | 30 | class TwoLinkedViewersCenterWidget(QtWidgets.QMainWindow): 31 | 32 | def __init__(self, parent=None, viewer1='2D', viewer2='2D'): 33 | QtWidgets.QMainWindow.__init__(self, parent) 34 | #self.resize(800,600) 35 | styles = [] 36 | viewers = [] 37 | 38 | for viewer in [viewer1, viewer2]: 39 | if viewer == '2D': 40 | styles.append(vlink.Linked2DInteractorStyle) 41 | elif viewer == '3D': 42 | styles.append(vlink.Linked3DInteractorStyle) 43 | viewers.append(eval('viewer' + viewer)) 44 | self.frame1 = QCILViewerWidget(parent, viewer=viewers[0], shape=(600, 600), interactorStyle=styles[0]) 45 | self.frame2 = QCILViewerWidget(parent, viewer=viewers[1], shape=(600, 600), interactorStyle=styles[1]) 46 | 47 | # Initially link viewers 48 | self.linkedViewersSetup() 49 | self.linker.enable() 50 | 51 | layout = QtWidgets.QBoxLayout(QtWidgets.QBoxLayout.LeftToRight) 52 | layout.addWidget(self.frame1) 53 | layout.addWidget(self.frame2) 54 | 55 | cw = QtWidgets.QWidget() 56 | cw.setLayout(layout) 57 | self.setCentralWidget(cw) 58 | self.central_widget = cw 59 | 60 | self.show() 61 | 62 | def linkedViewersSetup(self): 63 | v1 = self.frame1.viewer 64 | v2 = self.frame2.viewer 65 | self.linker = vlink.ViewerLinker(v1, v2) 66 | self.linker.setLinkPan(True) 67 | self.linker.setLinkZoom(True) 68 | self.linker.setLinkWindowLevel(True) 69 | self.linker.setLinkSlice(True) 70 | 71 | def set_input(self, data1, data2): 72 | self.frame1.viewer.setInputData(data1) 73 | self.frame2.viewer.setInputData(data2) 74 | 75 | 76 | class iviewer(object): 77 | ''' 78 | a Qt interactive viewer that can be used as plotter2D with one single dataset 79 | Parameters 80 | ---------- 81 | data: vtkImageData 82 | image to be displayed 83 | moredata: vtkImageData, optional 84 | extra image to be displayed 85 | viewer1: string - '2D' or '3D' 86 | the type of viewer to display the first image on 87 | viewer2: string - '2D' or '3D', optional 88 | the type of viewer to display the second image on (if present) 89 | 90 | ''' 91 | 92 | def __init__(self, data, *moredata, **kwargs): 93 | '''Creator''' 94 | app = QtWidgets.QApplication(sys.argv) 95 | self.app = app 96 | 97 | self.setUp(data, *moredata, **kwargs) 98 | self.show() 99 | 100 | def setUp(self, data, *moredata, **kwargs): 101 | if len(moredata) == 0: 102 | # can change the behaviour by setting which viewer you want 103 | # between viewer2D and viewer3D 104 | viewer_type = kwargs.get('viewer1', '2D') 105 | if viewer_type == '2D': 106 | viewer = viewer2D 107 | elif viewer_type == '3D': 108 | viewer = viewer3D 109 | window = SingleViewerCenterWidget(viewer=viewer) 110 | window.set_input(self.convert_to_vtkImage(data)) 111 | else: 112 | viewer1 = kwargs.get('viewer1', '2D') 113 | viewer2 = kwargs.get('viewer2', '2D') 114 | window = TwoLinkedViewersCenterWidget(viewer1=viewer1, viewer2=viewer2) 115 | window.set_input(self.convert_to_vtkImage(data), self.convert_to_vtkImage(moredata[0])) 116 | viewer_type = None 117 | self.viewer1_type = viewer1 118 | self.viewer2_type = viewer2 119 | 120 | self.window = window 121 | self.viewer_type = viewer_type 122 | self.has_run = None 123 | 124 | def show(self): 125 | if self.has_run is None: 126 | self.has_run = self.app.exec_() 127 | else: 128 | print('No instance can be run interactively again. Delete and re-instantiate.') 129 | 130 | def __del__(self): 131 | '''destructor''' 132 | self.app.exit() 133 | 134 | def convert_to_vtkImage(self, data): 135 | '''convert the data to vtkImageData for the viewer''' 136 | if isinstance(data, vtk.vtkImageData): 137 | vtkImage = data 138 | 139 | elif isinstance(data, np.ndarray): 140 | vtkImage = Converter.numpy2vtkImage(data) 141 | 142 | elif hasattr(data, 'as_array'): 143 | # this makes it likely it is a CIL/SIRF DataContainer 144 | # currently this will only deal with the actual data 145 | # but it will parse the metadata in future 146 | return self.convert_to_vtkImage(data.as_array()) 147 | 148 | return vtkImage 149 | 150 | 151 | if __name__ == "__main__": 152 | 153 | err = vtk.vtkFileOutputWindow() 154 | err.SetFileName("viewer.log") 155 | vtk.vtkOutputWindow.SetInstance(err) 156 | 157 | data = example_data.HEAD.get() 158 | iviewer(data, data, viewer1='2D', viewer2='3D') 159 | 160 | # To use your own metaimage file, uncomment: 161 | # reader = vtk.vtkMetaImageReader() 162 | # reader.SetFileName('head.mha') 163 | # reader.Update() 164 | #iviewer(reader.GetOutput(), reader.GetOutput(), viewer1='2D', viewer2='3D') 165 | -------------------------------------------------------------------------------- /Wrappers/Python/ccpi/viewer/standalone_viewer.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from functools import partial 3 | 4 | import vtk 5 | from ccpi.viewer import viewer2D, viewer3D 6 | from ccpi.viewer.ui.main_windows import TwoViewersMainWindow 7 | from PySide2 import QtWidgets 8 | from PySide2.QtWidgets import QAction, QCheckBox 9 | 10 | 11 | class StandaloneViewerMainWindow(TwoViewersMainWindow): 12 | ''' 13 | A main window for displaying two viewers side by side, with a menu bar 14 | for selecting the images to be displayed. 15 | 16 | The first image selected will be displayed on both viewers. The second 17 | is an image overlay which is displayed on the 2D viewer/s only. 18 | 19 | A dock widget is created which contains widgets for displaying the 20 | file name of the image shown on the viewer, and the level of downsampling 21 | of the image displayed on the viewer. 22 | ''' 23 | 24 | def __init__(self, 25 | title="StandaloneViewer", 26 | app_name="Standalone Viewer", 27 | settings_name=None, 28 | organisation_name=None, 29 | viewer1_type='2D', 30 | viewer2_type='3D'): 31 | super(StandaloneViewerMainWindow, self).__init__(title, app_name, settings_name, organisation_name, 32 | viewer1_type, viewer2_type) 33 | 34 | self.addToMenu() 35 | self.addToViewerCoordsDockWidget() 36 | 37 | self.image_overlay = vtk.vtkImageData() 38 | 39 | def addToMenu(self): 40 | ''' 41 | Adds actions to the menu bar for selecting the images to be displayed 42 | ''' 43 | file_menu = self.menus['File'] 44 | 45 | # insert image selection as first action in file menu: 46 | 47 | image2_action = QAction("Select Image Overlay", self) 48 | image2_action.triggered.connect(lambda: self.setViewersInputFromDialog(self.viewers, input_num=2)) 49 | file_menu.insertAction(file_menu.actions()[0], image2_action) 50 | 51 | image1_action = QAction("Select Image", self) 52 | image1_action.triggered.connect(lambda: self.setViewersInputFromDialog(self.viewers)) 53 | file_menu.insertAction(file_menu.actions()[0], image1_action) 54 | 55 | def addToViewerCoordsDockWidget(self): 56 | ''' 57 | Adds widgets to the viewer coords dock widget for displaying the 58 | image overlay, and for showing/hiding the 2D and 3D viewers. 59 | ''' 60 | checkbox = QCheckBox("Show Image Overlay") 61 | checkbox.setChecked(True) 62 | self.viewer_coords_dock.widget().addSpanningWidget(checkbox, 'image_overlay') 63 | checkbox.stateChanged.connect(partial(self.showHideImageOverlay)) 64 | 65 | checkbox2 = QCheckBox("Show 2D Viewer") 66 | checkbox2.setChecked(True) 67 | checkbox2.stateChanged.connect(partial(self.showHideViewer, 0)) 68 | 69 | checkbox3 = QCheckBox("Show 3D Viewer") 70 | checkbox3.setChecked(True) 71 | self.viewer_coords_dock.widget().addWidget(checkbox3, checkbox2, 'show_viewer') 72 | checkbox3.stateChanged.connect(partial(self.showHideViewer, 1)) 73 | 74 | def showHideImageOverlay(self, show=True): 75 | ''' 76 | Shows or hides the image overlay/s on the viewers 77 | ''' 78 | for viewer in self.viewer_coords_dock.viewers: 79 | if isinstance(viewer, viewer2D): 80 | if show: 81 | if hasattr(self, 'image_overlay'): 82 | viewer.setInputData2(self.image_overlay) 83 | else: 84 | self.image_overlay = viewer.image2 85 | viewer.setInputData2(vtk.vtkImageData()) 86 | 87 | 88 | class standalone_viewer(object): 89 | ''' 90 | Launches a StandaloneViewerMainWindow instance. 91 | 92 | Parameters: 93 | ------------ 94 | title: str 95 | title of the window 96 | viewer1_type: '2D' or '3D' 97 | viewer2_type: '2D', '3D' or None 98 | if None, only one viewer is displayed 99 | ''' 100 | 101 | def __init__(self, title="", viewer1_type='2D', viewer2_type='3D', *args, **kwargs): 102 | '''Creator 103 | 104 | Parameters: 105 | ------------ 106 | title: str 107 | title of the window 108 | viewer1_type: '2D' or '3D' 109 | viewer2_type: '2D', '3D' or None 110 | if None, only one viewer is displayed 111 | ''' 112 | app = QtWidgets.QApplication(sys.argv) 113 | self.app = app 114 | 115 | self.set_up(title, viewer1_type, viewer2_type, *args, **kwargs) 116 | 117 | def set_up(self, title, viewer1_type, viewer2_type=None, *args, **kwargs): 118 | ''' 119 | Sets up the standalone viewer. 120 | 121 | Parameters: 122 | ------------ 123 | title: str 124 | title of the window 125 | viewer1_type: '2D' or '3D' 126 | viewer2_type: '2D', '3D' or None 127 | if None, only one viewer is displayed 128 | ''' 129 | 130 | window = StandaloneViewerMainWindow(title, viewer1_type, viewer2_type) 131 | 132 | self.window = window 133 | self.has_run = None 134 | 135 | window.show() 136 | 137 | def show(self): 138 | ''' 139 | Shows the window 140 | ''' 141 | if self.has_run is None: 142 | self.has_run = self.app.exec_() 143 | else: 144 | print('No instance can be run interactively again. Delete and re-instantiate.') 145 | 146 | def __del__(self): 147 | '''destructor''' 148 | self.app.exit() 149 | 150 | 151 | def main(): 152 | # Run a standalone viewer with a 2D and a 3D viewer: 153 | err = vtk.vtkFileOutputWindow() 154 | err.SetFileName("viewer.log") 155 | vtk.vtkOutputWindow.SetInstance(err) 156 | standalone_viewer_instance = standalone_viewer("Standalone Viewer", viewer1_type='2D', viewer2_type='3D') 157 | standalone_viewer_instance.show() 158 | return 0 159 | 160 | 161 | if __name__ == '__main__': 162 | main() -------------------------------------------------------------------------------- /Wrappers/Python/ccpi/viewer/ui/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TomographicImaging/CILViewer/65c59499e25fc3a6131bae78201e83c097e0ce2b/Wrappers/Python/ccpi/viewer/ui/__init__.py -------------------------------------------------------------------------------- /Wrappers/Python/ccpi/viewer/ui/qt_widgets.py: -------------------------------------------------------------------------------- 1 | from eqt.ui.UIFormWidget import FormDockWidget 2 | from PySide2.QtWidgets import QComboBox, QLabel 3 | from ccpi.viewer.CILViewer import CILViewer 4 | from ccpi.viewer.CILViewer2D import CILViewer2D 5 | 6 | 7 | class ViewerCoordsDockWidget(FormDockWidget): 8 | ''' This is the dockwidget which 9 | shows the original and downsampled image 10 | size and the user can select whether coordinates 11 | are displayed in system of original or downsampled image''' 12 | 13 | def __init__(self, parent, title="Viewer Information", viewers=None): 14 | ''' 15 | Parameters 16 | ---------- 17 | parent : QWidget 18 | The parent widget. 19 | viewers : list of CILViewer2D and/or CILViewer instances, default = None 20 | The viewers which this dock widget will display information for. 21 | ''' 22 | super(ViewerCoordsDockWidget, self).__init__(parent, title) 23 | 24 | self.setViewers(viewers) 25 | 26 | form = self.widget() 27 | 28 | viewer_coords_widgets = {} 29 | 30 | viewer_coords_widgets['image'] = QLabel() 31 | viewer_coords_widgets['image'].setText("Display image: ") 32 | 33 | viewer_coords_widgets['image_combobox'] = QComboBox() 34 | viewer_coords_widgets['image_combobox'].setEnabled(False) 35 | form.addWidget(viewer_coords_widgets['image_combobox'], viewer_coords_widgets['image'], 'image') 36 | 37 | viewer_coords_widgets['coords_info'] = QLabel() 38 | viewer_coords_widgets['coords_info'].setText( 39 | "The viewer displays a downsampled image for visualisation purposes: ") 40 | viewer_coords_widgets['coords_info'].setVisible(False) 41 | 42 | form.addSpanningWidget(viewer_coords_widgets['coords_info'], 'coords_info') 43 | 44 | form.addWidget(QLabel(""), QLabel("Loaded Image Size: "), 'loaded_image_dims') 45 | 46 | viewer_coords_widgets['displayed_image_dims_label'] = QLabel() 47 | viewer_coords_widgets['displayed_image_dims_label'].setText("Displayed Image Size: ") 48 | viewer_coords_widgets['displayed_image_dims_label'].setVisible(False) 49 | 50 | viewer_coords_widgets['displayed_image_dims_field'] = QLabel() 51 | viewer_coords_widgets['displayed_image_dims_field'].setText("") 52 | viewer_coords_widgets['displayed_image_dims_field'].setVisible(False) 53 | 54 | form.addWidget(viewer_coords_widgets['displayed_image_dims_field'], 55 | viewer_coords_widgets['displayed_image_dims_label'], 'displayed_image_dims') 56 | 57 | viewer_coords_widgets['coords'] = QLabel() 58 | viewer_coords_widgets['coords'].setText("Display viewer coordinates in: ") 59 | 60 | viewer_coords_widgets['coords_combo'] = QComboBox() 61 | viewer_coords_widgets['coords_combo'].addItems(["Loaded Image", "Downsampled Image"]) 62 | viewer_coords_widgets['coords_combo'].setEnabled(False) 63 | form.addWidget(viewer_coords_widgets['coords_combo'], viewer_coords_widgets['coords'], 'coords_combo') 64 | 65 | viewer_coords_widgets['coords_warning'] = QLabel() 66 | viewer_coords_widgets['coords_warning'].setText("Warning: These coordinates are approximate.") 67 | viewer_coords_widgets['coords_warning'].setVisible(False) 68 | 69 | form.addSpanningWidget(viewer_coords_widgets['coords_warning'], 'coords_warning') 70 | 71 | @property 72 | def viewers(self): 73 | ''' Get the viewers which this dock widget will display information for. 74 | 75 | Returns 76 | ------- 77 | list of CILViewer2D and/or CILViewer3D instances 78 | The viewers which this dock widget will display information for.''' 79 | return self._viewers 80 | 81 | @viewers.setter 82 | def viewers(self, viewers): 83 | ''' 84 | Set the viewers which this dock widget will display information for. 85 | 86 | Parameters 87 | ---------- 88 | viewers : list of CILViewer2D and/or CILViewer instances 89 | The viewers which this dock widget will display information for. 90 | ''' 91 | if viewers is not None: 92 | if not isinstance(viewers, list): 93 | viewers = [viewers] 94 | for viewer in viewers: 95 | if not isinstance(viewer, (CILViewer2D, CILViewer)): 96 | raise TypeError("viewers must be a list of CILViewer2D and/or CILViewer instances") 97 | self._viewers = viewers 98 | 99 | def setViewers(self, viewers): 100 | ''' Set the viewers which this dock widget will display information for. 101 | 102 | Parameters 103 | ---------- 104 | viewers : list of CILViewer2D and/or CILViewer instances 105 | The viewers which this dock widget will display information for. 106 | ''' 107 | self.viewers = viewers 108 | 109 | def getViewers(self): 110 | ''' Get the viewers which this dock widget will display information for. 111 | 112 | Returns 113 | ------- 114 | list of CILViewer2D and/or CILViewer instances 115 | The viewers which this dock widget will display information for.''' 116 | return self.viewers 117 | -------------------------------------------------------------------------------- /Wrappers/Python/ccpi/viewer/undirected_graph.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Created on Sat May 12 13:35:53 2018 4 | 5 | @author: ofn77899 6 | """ 7 | 8 | import vtk 9 | from vtk import vtkGraphLayoutView 10 | 11 | 12 | def generate_data(): 13 | g = vtk.vtkMutableDirectedGraph() 14 | 15 | # add vertices 16 | v = [] 17 | for i in range(6): 18 | v.append(g.AddVertex()) 19 | 20 | g.AddEdge(v[0], v[1]) 21 | g.AddEdge(v[1], v[2]) 22 | g.AddEdge(v[1], v[3]) 23 | g.AddEdge(v[0], v[4]) 24 | g.AddEdge(v[0], v[5]) 25 | 26 | weights = vtk.vtkDoubleArray() 27 | weights.SetNumberOfComponents(1) 28 | weights.SetName("Weights") 29 | 30 | X = vtk.vtkDoubleArray() 31 | X.SetNumberOfComponents(1) 32 | X.SetName("X") 33 | 34 | Y = vtk.vtkDoubleArray() 35 | Y.SetNumberOfComponents(1) 36 | Y.SetName("Y") 37 | 38 | X.InsertNextValue(0) 39 | X.InsertNextValue(0) 40 | X.InsertNextValue(1) 41 | X.InsertNextValue(-1) 42 | X.InsertNextValue(0.5) 43 | X.InsertNextValue(-0.5) 44 | 45 | Y.InsertNextValue(0) 46 | Y.InsertNextValue(1) 47 | Y.InsertNextValue(1.5) 48 | Y.InsertNextValue(2) 49 | Y.InsertNextValue(-.5) 50 | Y.InsertNextValue(-.8) 51 | 52 | weights.InsertNextValue(1.) 53 | weights.InsertNextValue(2.) 54 | weights.InsertNextValue(3.) 55 | weights.InsertNextValue(4.) 56 | weights.InsertNextValue(5.) 57 | 58 | g.GetEdgeData().AddArray(weights) 59 | g.GetVertexData().AddArray(X) 60 | g.GetVertexData().AddArray(Y) 61 | 62 | return g 63 | 64 | 65 | class GraphInteractorStyle(vtk.vtkInteractorStyleRubberBand2D): 66 | 67 | def __init__(self, callback): 68 | vtk.vtkInteractorStyleRubberBand2D.__init__(self) 69 | self._viewer = callback 70 | 71 | self.AddObserver("MouseMoveEvent", self.OnMouseMoveEvent, 1.0) 72 | 73 | def GetEventPosition(self): 74 | return self.GetInteractor().GetEventPosition() 75 | 76 | def GetRenderer(self): 77 | return self._viewer.GetRenderer() 78 | 79 | def Render(self): 80 | self._viewer.Render() 81 | 82 | def display2world(self, viewerposition): 83 | vc = vtk.vtkCoordinate() 84 | vc.SetCoordinateSystemToViewport() 85 | vc.SetValue(viewerposition + (0.0, )) 86 | 87 | return vc.GetComputedWorldValue(self.GetRenderer()) 88 | 89 | def OnMouseMoveEvent(self, interactor, event): 90 | position = interactor.GetInteractor().GetEventPosition() 91 | world_position = self.display2world(position) 92 | level = world_position[1] * 100 93 | 94 | # Don't display values outside the graph scope 95 | if level < 0: 96 | level = 0 97 | if level > 100: 98 | level = 100 99 | 100 | # Create the label 101 | point_label = "Level: {:.1f} %".format(level) 102 | 103 | # Set the label and push change 104 | self.updateCornerAnnotation('pointAnnotation', point_label) 105 | self.Render() 106 | 107 | 108 | class UndirectedGraph(vtkGraphLayoutView): 109 | 110 | annotations = {"featureAnnotation": 0, "pointAnnotation": 2} 111 | 112 | def __init__(self, renWin=None, iren=None): 113 | super().__init__() 114 | 115 | if renWin: 116 | self.renWin = renWin 117 | else: 118 | self.renWin = vtk.vtkRenderWindow() 119 | if iren: 120 | self.iren = iren 121 | else: 122 | self.iren = vtk.vtkRenderWindowInteractor() 123 | 124 | self.SetRenderWindow(self.renWin) 125 | self.SetInteractor(self.iren) 126 | 127 | # Add observer to display level. 128 | self.iren.AddObserver("MouseMoveEvent", self.OnMouseMoveEvent, 1.) 129 | 130 | # Create corner annotations 131 | self.featureAnnotation = self.createCornerAnnotation() 132 | self.pointAnnotation = self.createCornerAnnotation() 133 | 134 | def update(self, input_data): 135 | 136 | # Create layout strategy 137 | layoutStrategy = vtk.vtkAssignCoordinatesLayoutStrategy() 138 | layoutStrategy.SetYCoordArrayName('Y') 139 | layoutStrategy.SetXCoordArrayName('X') 140 | 141 | self.AddRepresentationFromInput(input_data) 142 | self.SetVertexLabelArrayName("VertexID") 143 | self.SetVertexLabelVisibility(True) 144 | 145 | self.SetLayoutStrategy(layoutStrategy) 146 | 147 | annotation_link = vtk.vtkAnnotationLink() 148 | annotation_link.AddObserver("AnnotationChangedEvent", self.select_callback) 149 | self.GetRepresentation(0).SetAnnotationLink(annotation_link) 150 | self.GetRepresentation(0).SetScalingArrayName('VertexID') 151 | self.GetRepresentation(0).ScalingOn() 152 | 153 | self.ResetCamera() 154 | self.Render() 155 | 156 | def createCornerAnnotation(self): 157 | cornerAnnotation = vtk.vtkCornerAnnotation() 158 | cornerAnnotation.SetMaximumFontSize(12) 159 | cornerAnnotation.PickableOff() 160 | cornerAnnotation.VisibilityOff() 161 | cornerAnnotation.GetTextProperty().ShadowOn() 162 | self.GetRenderer().AddActor(cornerAnnotation) 163 | 164 | return cornerAnnotation 165 | 166 | def updateCornerAnnotation(self, target_annotation, label): 167 | 168 | annotation = getattr(self, target_annotation) 169 | 170 | # Make sure the annotation is visible 171 | if not annotation.GetVisibility(): 172 | annotation.VisibilityOn() 173 | 174 | # Get the correct corner 175 | corner_index = self.annotations[target_annotation] 176 | annotation.SetText(corner_index, label) 177 | 178 | def run(self, input_data): 179 | self.update(input_data) 180 | self.GetInteractor().Start() 181 | 182 | ######### INTERACTOR CALBACKS ######### 183 | def select_callback(self, interactor, event): 184 | sel = interactor.GetCurrentSelection() 185 | 186 | for nn in range(sel.GetNumberOfNodes()): 187 | sel_ids = sel.GetNode(nn).GetSelectionList() 188 | if sel_ids.GetNumberOfTuples() > 0: 189 | for ii in range(sel_ids.GetNumberOfTuples()): 190 | print(int(sel_ids.GetTuple1(ii))) 191 | 192 | def display2world(self, viewerposition): 193 | vc = vtk.vtkCoordinate() 194 | vc.SetCoordinateSystemToViewport() 195 | vc.SetValue(viewerposition + (0.0, )) 196 | 197 | return vc.GetComputedWorldValue(self.GetRenderer()) 198 | 199 | def OnMouseMoveEvent(self, interactor, event): 200 | 201 | # Allow default behaviour 202 | interactor.GetInteractorStyle().OnMouseMove() 203 | 204 | position = interactor.GetEventPosition() 205 | world_position = self.display2world(position) 206 | level = world_position[1] * 100 207 | 208 | # Don't display values outside the graph scope 209 | if level < 0: 210 | level = 0 211 | if level > 100: 212 | level = 100 213 | 214 | # Create the label 215 | point_label = "Level: {:.1f} %".format(level) 216 | 217 | # Set the label and push change 218 | self.updateCornerAnnotation('pointAnnotation', point_label) 219 | self.Render() 220 | 221 | 222 | if __name__ == "__main__": 223 | UndirectedGraph().run(generate_data()) 224 | -------------------------------------------------------------------------------- /Wrappers/Python/ccpi/viewer/utils/CameraData.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2022 STFC, United Kingdom Research and Innovation 3 | # 4 | # Author 2022 Samuel Jones, Laura Murgatroyd 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # http://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | from dataclasses import dataclass 20 | 21 | import vtk 22 | 23 | 24 | @dataclass(init=False) 25 | class CameraData: 26 | ''' 27 | A dataclass to store the camera position, focal point and view up 28 | ''' 29 | position: list 30 | focalPoint: list 31 | viewUp: list 32 | 33 | def __init__(self, camera: vtk.vtkCamera): 34 | self.position = camera.GetPosition() 35 | self.focalPoint = camera.GetFocalPoint() 36 | self.viewUp = camera.GetViewUp() 37 | 38 | @staticmethod 39 | def CopyDataToCamera(camera_data, vtkcamera): 40 | ''' 41 | Copy the camera_data to a vtkcamera 42 | 43 | Parameters 44 | ---------- 45 | camera_data : CameraData 46 | The camera data to copy 47 | vtkcamera : vtkCamera 48 | The vtk camera to copy to. 49 | ''' 50 | vtkcamera.SetPosition(*camera_data.position) 51 | vtkcamera.SetFocalPoint(*camera_data.focalPoint) 52 | vtkcamera.SetViewUp(*camera_data.viewUp) 53 | -------------------------------------------------------------------------------- /Wrappers/Python/ccpi/viewer/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from .conversion import * 2 | from .colormaps import * 3 | 4 | from .visualisation_pipeline import cilClipPolyDataBetweenPlanes, cilPlaneClipper, cilMaskPolyData 5 | 6 | from .CameraData import CameraData 7 | -------------------------------------------------------------------------------- /Wrappers/Python/ccpi/viewer/utils/error_handling.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | # See examples/error_observer.py for an example of using the ErrorObserver 4 | # and EndObserver. 5 | 6 | 7 | class ErrorObserver: 8 | 9 | def __init__(self, callback_fn=print): 10 | ''' 11 | Parameters: 12 | ----------- 13 | callback_fn : function, default print 14 | is called when an error occurs. It acts on the error message (str) 15 | so must be a function that takes a string as a parameter. 16 | ''' 17 | self.__error_occurred = False 18 | self.__get_error_message = None 19 | self.CallDataType = 'string0' 20 | self.callback_fn = callback_fn 21 | 22 | def __call__(self, obj, event, message): 23 | self.__error_occurred = True 24 | self.__get_error_message = message 25 | self.callback_fn(self.__get_error_message) 26 | 27 | def error_occurred(self): 28 | occ = self.__error_occurred 29 | self.__error_occurred = False 30 | return occ 31 | 32 | def get_error_message(self): 33 | return self.__get_error_message 34 | 35 | 36 | class EndObserver: 37 | ''' Occurs when the observed Algorithm finishes 38 | ''' 39 | 40 | def __init__(self, error_observer, callback_fn): 41 | ''' 42 | Parameters 43 | ---------- 44 | error_observer: ErrorObserver 45 | the error observer attached to the object the EndObserver is attached to 46 | callback_fn: function 47 | function to run if an error hasn't been identified by the error_observer''' 48 | self.callback_fn = callback_fn 49 | self.error_observer = error_observer 50 | self.CallDataType = 'string0' 51 | 52 | def __call__(self, obj, event): 53 | if not self.error_observer.error_occurred(): 54 | self.callback_fn() 55 | 56 | 57 | # Format warning messages: 58 | def customise_warnings(): 59 | # prints warning message in bold yellow, file name and line number 60 | # without customising, warning message is printed twice, and is not 61 | # as clear 62 | def custom_warning_format(message, category, filename, lineno, line=None): 63 | return "{1}:{2}: \033[1;33;40m {1}: {0} \033[0m \n ".format(message, category.__name__, filename, lineno) 64 | 65 | warnings.formatwarning = custom_warning_format 66 | -------------------------------------------------------------------------------- /Wrappers/Python/ccpi/viewer/utils/example_data.py: -------------------------------------------------------------------------------- 1 | import os 2 | import os.path 3 | import vtk 4 | import sys 5 | 6 | # this is the location where the cil-data module saves the datasets 7 | data_dir = os.path.abspath(os.path.join(sys.prefix, 'share', 'cil')) 8 | 9 | 10 | class DATA(object): 11 | 12 | @classmethod 13 | def dfile(cls): 14 | return None 15 | 16 | @classmethod 17 | def get(cls, **kwargs): 18 | ddir = kwargs.get('data_dir', data_dir) 19 | loader = TestData(data_dir=ddir) 20 | return loader.load(cls.dfile(), **kwargs) 21 | 22 | 23 | class HEAD(DATA): 24 | 25 | @classmethod 26 | def dfile(cls): 27 | return TestData.HEAD 28 | 29 | 30 | class TestData(object): 31 | '''Class to return test data 32 | 33 | provides 1 dataset: 34 | HEAD = 'head.mha' 35 | ''' 36 | HEAD = 'head.mha' 37 | 38 | def __init__(self, **kwargs): 39 | self.data_dir = kwargs.get('data_dir', data_dir) 40 | 41 | def load(self, which, **kwargs): 42 | ''' 43 | Return a test data of the requested image 44 | 45 | Parameters 46 | ---------- 47 | which: str 48 | Image selector: HEAD 49 | 50 | Returns 51 | ------- 52 | vtkImageData 53 | The loaded dataset 54 | ''' 55 | if which not in [TestData.HEAD]: 56 | raise ValueError('Unknown TestData {}.'.format(which)) 57 | 58 | data_file = os.path.join(self.data_dir, which) 59 | 60 | reader = vtk.vtkMetaImageReader() 61 | reader.SetFileName(data_file) 62 | reader.Update() 63 | return reader.GetOutput() 64 | -------------------------------------------------------------------------------- /Wrappers/Python/ccpi/viewer/utils/hdf5_io.py: -------------------------------------------------------------------------------- 1 | import vtk 2 | from vtk.util.vtkAlgorithm import VTKPythonAlgorithmBase 3 | import h5py 4 | import numpy as np 5 | from vtk.numpy_interface import dataset_adapter as dsa 6 | from vtk.util import numpy_support 7 | 8 | # Methods for reading and writing HDF5 files: 9 | 10 | 11 | def write_image_data_to_hdf5(filename, data, dataset_name, attributes={}): 12 | ''' 13 | Writes vtkImageData to a dataset in a HDF5 file 14 | 15 | Args: 16 | filename: string - name of HDF5 file to write to. 17 | data: vtkImageData - image data to write. 18 | DatasetName: string - DatasetName for HDF5 dataset. 19 | attributes: dict - attributes to assign to HDF5 dataset. 20 | ''' 21 | 22 | with h5py.File(filename, "a") as f: 23 | # The function imgdata.GetPointData().GetScalars() returns a pointer to a 24 | # vtkArray where the data is stored as X-Y-Z. 25 | array = numpy_support.vtk_to_numpy(data.GetPointData().GetScalars()) 26 | 27 | # Note that we flip the dimensions here because 28 | # VTK's order is Fortran whereas h5py writes in 29 | # C order. We don't want to do deep copies so we write 30 | # with dimensions flipped and pretend the array is 31 | # C order. 32 | array = array.reshape(data.GetDimensions()[::-1]) 33 | try: 34 | dset = f.create_dataset(dataset_name, data=array) 35 | except RuntimeError: 36 | print("Unable to save image data to {0}." 37 | "Dataset with name {1} already exists in this file.".format(filename, dataset_name)) 38 | return 39 | for key, value in attributes.items(): 40 | dset.attrs[key] = value 41 | 42 | 43 | class HDF5Reader(VTKPythonAlgorithmBase): 44 | ''' 45 | vtkAlgorithm for reading vtkImageData from a HDF5 file 46 | Adapted from: 47 | https://blog.kitware.com/developing-hdf5-readers-using-vtkpythonalgorithm/ 48 | To use this, you must set a FileName (the file you are reading), and also a 49 | DatasetName (the name of where in the hdf5 file the data will be saved) 50 | ''' 51 | 52 | def __init__(self): 53 | VTKPythonAlgorithmBase.__init__(self, nInputPorts=0, nOutputPorts=1, outputType='vtkImageData') 54 | 55 | self._FileName = None 56 | self._DatasetName = None 57 | self._4DSliceIndex = 0 58 | self._4DIndex = 0 59 | 60 | def RequestData(self, request, inInfo, outInfo): 61 | self._update_output_data(outInfo) 62 | return 1 63 | 64 | def _update_output_data(self, outInfo): 65 | if self._DatasetName is None: 66 | raise Exception("DataSetName must be set.") 67 | if self._FileName is None: 68 | raise Exception("FileName must be set.") 69 | with h5py.File(self._FileName, 'r') as f: 70 | info = outInfo.GetInformationObject(0) 71 | shape = np.shape(f[self._DatasetName]) 72 | # print("keys:", list(f.keys())) 73 | # print(shape) 74 | ue = info.Get(vtk.vtkStreamingDemandDrivenPipeline.UPDATE_EXTENT()) 75 | # Note that we flip the update extents because VTK is Fortran order 76 | # whereas h5py reads in C order. When writing we pretend that the 77 | # data was C order so we have to flip the extents/dimensions. 78 | if len(shape) == 3: 79 | data = f[self._DatasetName][ue[4]:ue[5] + 1, ue[2]:ue[3] + 1, ue[0]:ue[1] + 1] 80 | elif len(shape) == 4: 81 | if self._4DIndex == 0: 82 | data = f[self._DatasetName][self._4DSliceIndex, ue[4]:ue[5] + 1, ue[2]:ue[3] + 1, ue[0]:ue[1] + 1] 83 | elif self._4DIndex == 1: 84 | data = f[self._DatasetName][ue[4]:ue[5] + 1, self._4DSliceIndex, ue[2]:ue[3] + 1, ue[0]:ue[1] + 1] 85 | elif self._4DIndex == 2: 86 | data = f[self._DatasetName][ue[4]:ue[5] + 1, ue[2]:ue[3] + 1, self._4DSliceIndex, ue[0]:ue[1] + 1] 87 | elif self._4DIndex == 3: 88 | data = f[self._DatasetName][ue[4]:ue[5] + 1, ue[2]:ue[3] + 1, ue[0]:ue[1] + 1, self._4DSliceIndex] 89 | else: 90 | raise Exception("Currently only 3D and 4D datasets are supported.") 91 | # print("attributes: ", f.attrs.items()) 92 | output = dsa.WrapDataObject(vtk.vtkImageData.GetData(outInfo)) 93 | output.SetExtent(ue) 94 | output.PointData.append(data.ravel(), self._DatasetName) 95 | output.PointData.SetActiveScalars(self._DatasetName) 96 | return output 97 | 98 | def SetFileName(self, fname): 99 | if fname != self._FileName: 100 | self.Modified() 101 | if self._DatasetName is not None: 102 | with h5py.File(fname, 'r') as f: 103 | if not (self._DatasetName in f): 104 | raise Exception("No dataset named {} exists in {}.".format(self._DatasetName, fname)) 105 | self._FileName = fname 106 | 107 | def GetFileName(self): 108 | return self._FileName 109 | 110 | def SetDatasetName(self, lname): 111 | if lname != self._DatasetName: 112 | self.Modified() 113 | if self._FileName is not None: 114 | with h5py.File(self._FileName, 'r') as f: 115 | if not (lname in f): 116 | raise Exception("No dataset named {} exists in {}.".format(lname, self._FileName)) 117 | self._DatasetName = lname 118 | 119 | def GetDatasetName(self): 120 | return self._DatasetName 121 | 122 | def Set4DIndex(self, index): 123 | '''Sets which index is the 4th dimension that we will only read 1 slice of.''' 124 | if index not in range(0, 4): 125 | raise Exception("4D Index must be between 0 and 3.") 126 | if index != self._4DIndex: 127 | self.Modified() 128 | self._4DIndex = index 129 | 130 | def Set4DSliceIndex(self, index): 131 | '''Sets which index to read along the 4th dimension.''' 132 | if index != self._4DSliceIndex: 133 | self.Modified() 134 | self._4DSliceIndex = index 135 | 136 | def GetDimensions(self): 137 | if self._FileName is None: 138 | raise Exception("FileName must be set.") 139 | with h5py.File(self._FileName, 'r') as f: 140 | # Note that we flip the shape because VTK is Fortran order 141 | # whereas h5py reads in C order. When writing we pretend that the 142 | # data was C order so we have to flip the extents/dimensions. 143 | if self._DatasetName is None: 144 | raise Exception("DataSetName must be set.") 145 | return f[self._DatasetName].shape[::-1] 146 | 147 | def GetDataSetAttributes(self): 148 | if self._FileName is None: 149 | raise Exception("FileName must be set.") 150 | with h5py.File(self._FileName, 'r') as f: 151 | if self._DatasetName is None: 152 | raise Exception("DataSetName must be set.") 153 | return dict(f[self._DatasetName].attrs) 154 | 155 | def GetOrigin(self): 156 | # There is not a standard way to set the origin in a HDF5 157 | # file so we do not have a way to read it. Therefore we 158 | # assume it is at 0,0,0 159 | return (0, 0, 0) 160 | 161 | def GetDataType(self): 162 | if self._FileName is None: 163 | raise Exception("FileName must be set.") 164 | with h5py.File(self._FileName, 'r') as f: 165 | data_type = f.get(self._DatasetName).dtype 166 | return data_type 167 | 168 | def RequestInformation(self, request, inInfo, outInfo): 169 | dims = self.GetDimensions() 170 | info = outInfo.GetInformationObject(0) 171 | info.Set(vtk.vtkStreamingDemandDrivenPipeline.WHOLE_EXTENT(), (0, dims[0] - 1, 0, dims[1] - 1, 0, dims[2] - 1), 172 | 6) 173 | return 1 174 | 175 | 176 | class HDF5SubsetReader(VTKPythonAlgorithmBase): 177 | '''Modifies a HDF5Reader to return a different extent from an HDF5 file 178 | 179 | 180 | Examples: 181 | --------- 182 | 183 | reader = HDF5Reader() 184 | reader.SetFileName('file.h5') 185 | reader.SetDatasetName("ImageData") 186 | 187 | cropped_reader = HDF5SubsetReader() 188 | cropped_reader.SetInputConnection(reader.GetOutputPort()) 189 | cropped_reader.SetUpdateExtent((0, 2, 3, 5, 1, 2)) 190 | cropped_reader.Update() 191 | ''' 192 | 193 | def __init__(self): 194 | VTKPythonAlgorithmBase.__init__(self, 195 | nInputPorts=1, 196 | inputType='vtkImageData', 197 | nOutputPorts=1, 198 | outputType='vtkImageData') 199 | self.__UpdateExtent = None 200 | 201 | def RequestInformation(self, request, inInfo, outInfo): 202 | info = outInfo.GetInformationObject(0) 203 | info.Set(vtk.vtkStreamingDemandDrivenPipeline.WHOLE_EXTENT(), self.__UpdateExtent, 6) 204 | return 1 205 | 206 | def RequestUpdateExtent(self, request, inInfo, outInfo): 207 | if self.__UpdateExtent is not None: 208 | info = inInfo[0].GetInformationObject(0) 209 | 210 | whole_extent = info.Get(vtk.vtkStreamingDemandDrivenPipeline.WHOLE_EXTENT()) 211 | set_extent = list(info.Get(vtk.vtkStreamingDemandDrivenPipeline.UPDATE_EXTENT())) 212 | 213 | for i, value in enumerate(set_extent): 214 | if value == -1: 215 | set_extent[i] = whole_extent[i] 216 | else: 217 | if i % 2 == 0: 218 | if value < whole_extent[i]: 219 | raise ValueError("Requested extent {}\ 220 | is outside of original image extent {} as {}<{}".format( 221 | set_extent, whole_extent, value, whole_extent[i])) 222 | else: 223 | if value > whole_extent[i]: 224 | raise ValueError("Requested extent {}\ 225 | is outside of original image extent {} as {}>{}".format( 226 | set_extent, whole_extent, value, whole_extent[i])) 227 | 228 | self.SetUpdateExtent(set_extent) 229 | 230 | info.Set(vtk.vtkStreamingDemandDrivenPipeline.UPDATE_EXTENT(), self.__UpdateExtent, 6) 231 | return 1 232 | 233 | def RequestData(self, request, inInfo, outInfo): 234 | inp = vtk.vtkImageData.GetData(inInfo[0]) 235 | opt = vtk.vtkImageData.GetData(outInfo) 236 | opt.ShallowCopy(inp) 237 | return 1 238 | 239 | def SetUpdateExtent(self, ue): 240 | if ue != self.__UpdateExtent: 241 | self.Modified() 242 | self.__UpdateExtent = ue 243 | 244 | def GetUpdateExtent(self): 245 | return self.__UpdateExtent 246 | 247 | def GetOutput(self): 248 | return self.GetOutputDataObject(0) 249 | -------------------------------------------------------------------------------- /Wrappers/Python/ccpi/viewer/widgets/__init__.py: -------------------------------------------------------------------------------- 1 | from .box_widgets import cilviewerBoxWidget, cilviewerLineWidget 2 | from .slider import SliderCallback, SliceSliderRepresentation 3 | -------------------------------------------------------------------------------- /Wrappers/Python/ccpi/viewer/widgets/slider.py: -------------------------------------------------------------------------------- 1 | import vtk 2 | import logging 3 | 4 | logger = logging.getLogger(__name__) 5 | # Define a new event type 6 | SLIDER_EVENT = vtk.vtkCommand.UserEvent + 1 7 | 8 | 9 | class SliceSliderRepresentation(vtk.vtkSliderRepresentation2D): 10 | """A slider representation for the slice selector slider on a 2D CILViewer 11 | 12 | Parameters 13 | ----------- 14 | orientation: str, optional 15 | The orientation of the slider. Can be 'horizontal' or 'vertical' 16 | offset: float, optional 17 | The offset of the slider from the edge of the window. Default is 0.12 18 | 19 | """ 20 | 21 | def __init__(self, orientation='horizontal', offset=0.12): 22 | self.tube_width = 0.004 23 | self.slider_length = 0.015 24 | self.slider_width = 0.015 25 | self.end_cap_length = 0.008 26 | self.end_cap_width = 0.02 27 | self.title_height = 0.02 28 | self.label_height = 0.02 29 | self.bar_color = 'Gray' 30 | cil_pink = [[el / 0xff for el in [0xe5, 0x06, 0x95]], [el / 0xff for el in [0xc9, 0x2c, 0x99]], 31 | [el / 0xff for el in [0x99, 0x3d, 0xbb]], [el / 0xff for el in [0x51, 0x0c, 0x76]]] 32 | 33 | self.orientation = 'horizontal' 34 | self.offset = 0.12 35 | 36 | self.p1 = [self.offset, self.end_cap_width * 1.1] 37 | self.p2 = [1 - self.offset, self.end_cap_width * 1.1] 38 | 39 | self.title = None 40 | 41 | if orientation == 'vertical': 42 | self.offset = offset 43 | self.p1 = [self.end_cap_width * 1.1, self.offset] 44 | self.p2 = [self.end_cap_width * 1.1, 1 - self.offset] 45 | 46 | self.SetTitleText(self.title) 47 | 48 | self.GetPoint1Coordinate().SetCoordinateSystemToNormalizedDisplay() 49 | self.GetPoint1Coordinate().SetValue(self.p1[0], self.p1[1]) 50 | self.GetPoint2Coordinate().SetCoordinateSystemToNormalizedDisplay() 51 | self.GetPoint2Coordinate().SetValue(self.p2[0], self.p2[1]) 52 | 53 | self.SetTubeWidth(self.tube_width) 54 | self.SetSliderLength(self.slider_length) 55 | # slider_width = self.tube_width 56 | # slider.SetSliderWidth(slider_width) 57 | self.SetEndCapLength(self.end_cap_length) 58 | self.SetEndCapWidth(self.end_cap_width) 59 | self.SetTitleHeight(self.title_height) 60 | self.SetLabelHeight(self.label_height) 61 | 62 | # Set the colors of the slider components. 63 | # Change the color of the bar. 64 | self.GetTubeProperty().SetColor(vtk.vtkNamedColors().GetColor3d(self.bar_color)) 65 | # Change the color of the ends of the bar. 66 | self.GetCapProperty().SetColor(cil_pink[2]) 67 | # Change the color of the knob that slides. 68 | self.GetSliderProperty().SetColor(cil_pink[1]) 69 | # Change the color of the knob when the mouse is held on it. 70 | self.GetSelectedProperty().SetColor(cil_pink[0]) 71 | 72 | 73 | class SliderCallback: 74 | ''' 75 | Class to propagate the effects of interaction between the slider widget and the viewer 76 | the slider is embedded into, and viceversa. 77 | 78 | Parameters 79 | ----------- 80 | viewer : CILViewer2D the slider is embedded into 81 | slider_widget : the vtkSliderWidget that is embedded in the viewer 82 | ''' 83 | 84 | def __init__(self, viewer, slider_widget): 85 | self.viewer = viewer 86 | self.slider_widget = slider_widget 87 | 88 | def __call__(self, caller, ev): 89 | '''Update the slice displayed by the viewer when the slider is moved 90 | 91 | Parameters 92 | ----------- 93 | caller : the slider widget 94 | ev : the event that triggered the update 95 | ''' 96 | slider_widget = caller 97 | value = slider_widget.GetRepresentation().GetValue() 98 | self.viewer.displaySlice(int(value)) 99 | self.update_label(value) 100 | self.viewer.getInteractor().InvokeEvent(SLIDER_EVENT) 101 | 102 | def update_label(self, value): 103 | '''Update the text label on the slider. This is called by update_from_viewer 104 | 105 | Parameters 106 | ----------- 107 | value : the value to be displayed on text label the slider 108 | ''' 109 | rep = self.slider_widget.GetRepresentation() 110 | maxval = rep.GetMaximumValue() 111 | txt = "{}/{}".format(int(value), int(maxval)) 112 | rep.SetLabelFormat(txt) 113 | 114 | def update_from_viewer(self, caller, ev): 115 | '''Update the slider widget from the viewer. This is called when the viewer changes the slice 116 | 117 | Parameters 118 | ----------- 119 | caller : the interactor style 120 | ev : the event that triggered the update 121 | ''' 122 | # The caller is the interactor style 123 | logger.info(f"Updating for event {ev}") 124 | value = caller.GetActiveSlice() 125 | self.slider_widget.GetRepresentation().SetValue(value) 126 | self.update_label(value) 127 | caller.GetRenderWindow().Render() 128 | 129 | def update_orientation(self, caller, ev): 130 | '''Update the slider widget when the orientation is changed 131 | 132 | Parameters 133 | ----------- 134 | caller : the interactor style 135 | ev : the event that triggered the update 136 | ''' 137 | logger.info(f"Updating orientation {ev}") 138 | value = caller.GetActiveSlice() 139 | dims = caller._viewer.img3D.GetDimensions() 140 | maxslice = dims[caller.GetSliceOrientation()] - 1 141 | sr = self.slider_widget.GetRepresentation() 142 | sr.SetMinimumValue(0) 143 | sr.SetMaximumValue(maxslice) 144 | self.update_from_viewer(caller, ev) 145 | -------------------------------------------------------------------------------- /Wrappers/Python/ccpi/web_viewer/README.md: -------------------------------------------------------------------------------- 1 | To setup your environment for using the web application we recommend using conda as it can simplify things in comparison to other implementations of python environment handling. 2 | 3 | Follow these steps: 4 | - Install a variant of conda. For this example we will use mambaforge (includes mamba, a faster conda implementation, and has conda-forge added as a default channel). It can be downloaded here: https://github.com/conda-forge/miniforge/releases 5 | - Create the conda environment: 6 | - `mamba env create -f dev-environment.yml` 7 | - Activate the environment 8 | - `mamba activate cilviewer_webapp` 9 | - Install the app in the environment from the `CILViewer` directory 10 | - `pip install ./Wrappers/Python` 11 | - Start the web application 12 | - `web_cilviewer path/to/folder/of/data/to/use` 13 | where `path/to/folder/of/data/to/use` is the folder storing the data i.e. no filename included 14 | - Pass the 2D arg to the script if you want to use the 2D viewer i.e. `--2D` or `-d args`. This needs to be added before the path 15 | - `web_cilviewer --2D path/to/folder/of/data/to/use` 16 | -------------------------------------------------------------------------------- /Wrappers/Python/ccpi/web_viewer/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2022 STFC, United Kingdom Research and Innovation 3 | # 4 | # Author 2022 Samuel Jones 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # http://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | -------------------------------------------------------------------------------- /Wrappers/Python/ccpi/web_viewer/dev-environment.yml: -------------------------------------------------------------------------------- 1 | # run: conda env create --file environment.yml 2 | name: cilviewer_webapp 3 | channels: 4 | - conda-forge 5 | - ccpi 6 | dependencies: 7 | - python==3.9 8 | - matplotlib # Optional for more colormaps 9 | - h5py 10 | - pip 11 | - schema 12 | - pyyaml 13 | - pip: 14 | # Have to install Trame via pip due to unavailability on conda 15 | - trame <3, >=2.1.1 # Unpinned worked with version 2.1.1, should work with higher versions. 16 | - vtk==9.1 17 | -------------------------------------------------------------------------------- /Wrappers/Python/ccpi/web_viewer/test/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2022 STFC, United Kingdom Research and Innovation 3 | # 4 | # Author 2022 Samuel Jones 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # http://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | -------------------------------------------------------------------------------- /Wrappers/Python/ccpi/web_viewer/test/test_web_app.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2022 STFC, United Kingdom Research and Innovation 3 | # 4 | # Author 2022 Samuel Jones 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # http://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | import unittest 19 | from unittest import mock 20 | 21 | from ccpi.web_viewer.web_app import arg_parser, reset_viewer2d, data_finder, set_viewer2d, main, change_orientation, change_opacity_mapping 22 | 23 | 24 | class WebAppTest(unittest.TestCase): 25 | 26 | def setUp(self): 27 | reset_viewer2d() 28 | 29 | @mock.patch("ccpi.web_viewer.web_app.print") 30 | @mock.patch("ccpi.web_viewer.web_app.sys") 31 | def test_arg_parser_handles_h(self, sys, print_output): 32 | help_string = "web_app.py [optional args: -h, -d] \n" \ 33 | "Args:\n" \ 34 | "-h: Show this help and exit the program\n" \ 35 | "-d, --2D: Use the 2D viewer instead of the 3D viewer, the default is to just use the 3D viewer." 36 | sys.argv = ["python_file.py", "-h"] 37 | arg_parser() 38 | 39 | print_output.assert_called_once_with(help_string) 40 | 41 | @mock.patch("ccpi.web_viewer.web_app.print") 42 | @mock.patch("ccpi.web_viewer.web_app.sys") 43 | def test_arg_parser_handles_2d(self, sys, print_output): 44 | from ccpi.web_viewer.web_app import VIEWER_2D 45 | self.assertEqual(VIEWER_2D, False) 46 | sys.argv = ["python_file.py", "--2D"] 47 | arg_parser() 48 | 49 | from ccpi.web_viewer.web_app import VIEWER_2D 50 | self.assertEqual(VIEWER_2D, True) 51 | print_output.assert_not_called() 52 | 53 | @mock.patch("ccpi.web_viewer.web_app.print") 54 | @mock.patch("ccpi.web_viewer.web_app.sys") 55 | def test_arg_parser_handles_d(self, sys, print_output): 56 | from ccpi.web_viewer.web_app import VIEWER_2D 57 | self.assertEqual(VIEWER_2D, False) 58 | sys.argv = ["python_file.py", "-d"] 59 | arg_parser() 60 | 61 | from ccpi.web_viewer.web_app import VIEWER_2D 62 | self.assertEqual(VIEWER_2D, True) 63 | print_output.assert_not_called() 64 | 65 | @mock.patch("ccpi.web_viewer.web_app.print") 66 | @mock.patch("ccpi.web_viewer.web_app.sys") 67 | def test_arg_parser_does_not_do_anything_with_unused_args(self, sys, print_output): 68 | sys.argv = ["python_file.py", "file/path/to/path"] 69 | arg_parser() 70 | 71 | print_output.assert_called_once_with( 72 | "This arg: file/path/to/path is not a valid file or directory. Assuming it is for trame.") 73 | 74 | @mock.patch("ccpi.web_viewer.web_app.os") 75 | @mock.patch("ccpi.web_viewer.web_app.print") 76 | @mock.patch("ccpi.web_viewer.web_app.sys") 77 | def test_data_finder_handles_directory_that_was_passed(self, sys, print_output, os): 78 | sys.argv = ["python_file.py", "dir/path/to/path"] 79 | os.listdir.return_value = ["path/to/file1.txt", "path/to/file2.txt"] 80 | os.path.isfile.return_value = False 81 | os.path.isdir.return_value = True 82 | os.path.join.return_value = "path/to/file1.txt" 83 | 84 | return_value = data_finder() 85 | 86 | os.listdir.assert_called_once_with("dir/path/to/path") 87 | print_output.assert_not_called() 88 | self.assertEqual(return_value, ["path/to/file1.txt", "path/to/file1.txt"]) 89 | 90 | @mock.patch("ccpi.web_viewer.web_app.os") 91 | @mock.patch("ccpi.web_viewer.web_app.print") 92 | @mock.patch("ccpi.web_viewer.web_app.sys") 93 | def test_data_finder_handles_file_that_was_passed(self, sys, print_output, os): 94 | sys.argv = ["python_file.py", "/path/to/file.txt"] 95 | os.path.isfile.return_value = True 96 | os.path.isdir.return_value = True 97 | 98 | return_value = data_finder() 99 | 100 | print_output.assert_not_called() 101 | self.assertEqual(return_value, ["/path/to/file.txt"]) 102 | 103 | @mock.patch("ccpi.web_viewer.web_app.os") 104 | @mock.patch("ccpi.web_viewer.web_app.print") 105 | @mock.patch("ccpi.web_viewer.web_app.sys") 106 | def test_data_finder_handles_multiple_passed_args(self, sys, print_output, os): 107 | sys.argv = ["python_file.py", "path/to/file1.txt", "path/to/file2.txt"] 108 | os.listdir.return_value = ["path/to/file1.txt", "path/to/file2.txt"] 109 | os.path.isfile.return_value = True 110 | os.path.isdir.return_value = False 111 | os.path.join.return_value = "path/to/file1.txt" 112 | 113 | return_value = data_finder() 114 | 115 | print_output.assert_not_called() 116 | self.assertEqual(return_value, ["path/to/file1.txt", "path/to/file2.txt"]) 117 | 118 | @mock.patch("ccpi.web_viewer.web_app.arg_parser") 119 | @mock.patch("ccpi.web_viewer.web_app.TrameViewer2D") 120 | @mock.patch("ccpi.web_viewer.web_app.TrameViewer3D") 121 | def test_main_creates_trame_viewer_2d_when_VIEWER_2D_is_true(self, viewer3d, viewer2d, arg_parser): 122 | set_viewer2d(True) 123 | data_files = mock.MagicMock() 124 | arg_parser.return_value = data_files 125 | 126 | main() 127 | 128 | viewer3d.assert_not_called() 129 | viewer2d.assert_called_once_with(data_files) 130 | viewer2d.return_value.start.assert_called_once() 131 | 132 | @mock.patch("ccpi.web_viewer.web_app.arg_parser") 133 | @mock.patch("ccpi.web_viewer.web_app.TrameViewer2D") 134 | @mock.patch("ccpi.web_viewer.web_app.TrameViewer3D") 135 | def test_main_creates_trame_viewer_3d_when_VIEWER_2D_is_false(self, viewer3d, viewer2d, arg_parser): 136 | set_viewer2d(False) 137 | data_files = mock.MagicMock() 138 | arg_parser.return_value = data_files 139 | 140 | main() 141 | 142 | viewer3d.assert_called_once_with(data_files) 143 | viewer3d.return_value.start.assert_called_once() 144 | viewer2d.assert_not_called() 145 | 146 | @mock.patch("ccpi.web_viewer.web_app.TRAME_VIEWER") 147 | def test_change_orientation_orientation_not_kwargs_calls_nothing(self, trame_viewer): 148 | change_orientation() 149 | 150 | trame_viewer.switch_to_orientation.assert_not_called() 151 | 152 | @mock.patch("ccpi.web_viewer.web_app.TRAME_VIEWER") 153 | def test_change_orientation_is_not_an_int_gets_cast_to_int_before_passed(self, trame_viewer): 154 | change_orientation(orientation="0") 155 | 156 | trame_viewer.switch_to_orientation.assert_called_once_with(0) 157 | 158 | @mock.patch("ccpi.web_viewer.web_app.TRAME_VIEWER") 159 | def test_change_opacity_mapping_not_kwargs_calls_nothing(self, trame_viewer): 160 | change_opacity_mapping() 161 | 162 | trame_viewer.set_opacity_mapping.assert_not_called() 163 | -------------------------------------------------------------------------------- /Wrappers/Python/ccpi/web_viewer/trame_viewer2D.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2022 STFC, United Kingdom Research and Innovation 3 | # 4 | # Author 2022 Samuel Jones 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # http://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | from trame.app import get_server 19 | from trame.widgets import vuetify 20 | 21 | from ccpi.viewer.CILViewer2D import CILViewer2D, SLICE_ORIENTATION_XY 22 | from ccpi.web_viewer.trame_viewer import TrameViewer 23 | 24 | server = get_server() 25 | state, ctrl = server.state, server.controller 26 | 27 | 28 | class TrameViewer2D(TrameViewer): 29 | 30 | def __init__(self, list_of_files: list = None): 31 | self.first_load = True 32 | super().__init__(list_of_files=list_of_files, viewer=CILViewer2D) 33 | 34 | self.model_choice = None 35 | self.background_choice = None 36 | self.slice_slider = None 37 | self.orientation_radio_buttons = None 38 | self.auto_window_level_button = None 39 | self.toggle_window_details_button = None 40 | self.slice_window_range_slider = None 41 | self.slice_window_slider = None 42 | self.slice_level_slider = None 43 | self.tracing_switch = None 44 | self.line_profile_switch = None 45 | self.interpolation_of_slice_switch = None 46 | self.remove_roi_button = None 47 | self.reset_defaults_button = None 48 | self.slice_interaction_col = None 49 | self.slice_interaction_row = None 50 | self.slice_interaction_section = None 51 | 52 | self.create_drawer_ui_elements() 53 | 54 | self.construct_drawer_layout() 55 | 56 | self.layout.content.children = [ 57 | vuetify.VContainer(fluid=True, classes="pa-0 fill-height", children=[self.html_view]) 58 | ] 59 | self.reset_defaults() 60 | self.layout.flush_content() 61 | 62 | def create_drawer_ui_elements(self): 63 | self.model_choice = self.create_model_selector() 64 | self.background_choice = self.create_background_selector() 65 | self.slice_slider = self.create_slice_slider() 66 | self.orientation_radio_buttons = self.create_orientation_radio_buttons() 67 | self.toggle_window_details_button = self.create_toggle_window_details_button() 68 | self.auto_window_level_button = self.create_auto_window_level_button() 69 | self.slice_window_range_slider = self.construct_slice_window_range_slider() 70 | self.slice_window_slider = self.construct_slice_window_slider() 71 | self.slice_level_slider = self.construct_slice_level_slider() 72 | self.tracing_switch = self.create_tracing_switch() 73 | self.interpolation_of_slice_switch = self.create_interpolation_of_slice_switch() 74 | self.remove_roi_button = self.create_remove_roi_button() 75 | self.reset_defaults_button = self.create_reset_defaults_button() 76 | 77 | def construct_drawer_layout(self): 78 | # The difference is that we use range slider instead of detailed sliders 79 | self.slice_interaction_col = vuetify.VCol([ 80 | self.slice_slider, self.orientation_radio_buttons, self.auto_window_level_button, 81 | self.toggle_window_details_button, self.slice_window_range_slider, self.slice_window_slider, 82 | self.slice_level_slider, self.tracing_switch, self.interpolation_of_slice_switch 83 | ]) 84 | self.slice_interaction_row = vuetify.VRow(self.slice_interaction_col) 85 | self.slice_interaction_section = vuetify.VContainer(self.slice_interaction_row) 86 | self.layout.drawer.children = [ 87 | "Choose model to load", self.model_choice, 88 | vuetify.VDivider(), "Choose background color", self.background_choice, 89 | vuetify.VDivider(), self.slice_interaction_section, 90 | vuetify.VDivider(), 91 | "Use Ctrl + Click on the slice, to show the ROI of the current slice, Click and drag to resize and reposition.\n" 92 | "Move the ROI by using the middle mouse button.", 93 | vuetify.VDivider(), self.remove_roi_button, 94 | vuetify.VDivider(), self.reset_defaults_button 95 | ] 96 | 97 | def load_file(self, file_name, _="scalar"): 98 | super().load_file(file_name, windowing_method="scalar") 99 | if not self.first_load: 100 | self.update_slice_slider_data() 101 | self.update_slice_windowing_defaults() 102 | self.create_drawer_ui_elements() 103 | self.construct_drawer_layout() 104 | self.reset_defaults() 105 | else: 106 | self.first_load = False 107 | 108 | def update_slice_windowing_defaults(self): 109 | self.update_slice_data() 110 | 111 | if hasattr(self, "slice_window_range_slider") and self.slice_window_range_slider: 112 | self.slice_window_range_slider = self.construct_slice_window_range_slider() 113 | self.slice_level_slider = self.construct_slice_level_slider() 114 | self.slice_window_slider = self.construct_slice_window_slider() 115 | self.construct_drawer_layout() 116 | self.layout.flush_content() 117 | 118 | def create_remove_roi_button(self): 119 | return vuetify.VBtn("Remove ROI", hide_details=True, dense=True, solo=True, click=self.remove_roi) 120 | 121 | def create_tracing_switch(self): 122 | return vuetify.VSwitch(label="Toggle Tracing", 123 | v_model=("toggle_tracing", False), 124 | hide_details=True, 125 | dense=True, 126 | solo=True) 127 | 128 | def create_interpolation_of_slice_switch(self): 129 | return vuetify.VSwitch( 130 | label="Toggle Interpolation", 131 | v_model=("toggle_interpolation", False), 132 | hide_details=True, 133 | dense=True, 134 | solo=True, 135 | ) 136 | 137 | def create_reset_defaults_button(self): 138 | return vuetify.VBtn("Reset Defaults", hide_details=True, dense=True, solo=True, click=self.reset_defaults) 139 | 140 | def change_tracing(self, tracing: bool): 141 | if tracing: 142 | self.cil_viewer.imageTracer.On() 143 | else: 144 | self.cil_viewer.imageTracer.Off() 145 | 146 | def change_interpolation(self, interpolation: bool): 147 | if not interpolation: 148 | self.cil_viewer.imageSlice.GetProperty().SetInterpolationTypeToNearest() 149 | else: 150 | self.cil_viewer.imageSlice.GetProperty().SetInterpolationTypeToLinear() 151 | self.cil_viewer.updatePipeline() 152 | 153 | def change_window_level_detail_sliders(self, show_detailed: bool): 154 | super().change_window_level_detail_sliders(show_detailed) 155 | 156 | # Reconstruct the detailed sliders 157 | self.slice_window_range_slider = self.construct_slice_window_range_slider() 158 | self.slice_window_slider = self.construct_slice_window_slider() 159 | self.slice_level_slider = self.construct_slice_level_slider() 160 | 161 | # Reconstruct the drawer and push it 162 | self.construct_drawer_layout() 163 | self.layout.flush_content() 164 | 165 | def remove_roi(self): 166 | self.cil_viewer.style.RemoveROIWidget() 167 | 168 | def reset_defaults(self): 169 | state["background_color"] = "cil_viewer_blue" 170 | state["slice"] = self.default_slice 171 | state["orientation"] = f"{SLICE_ORIENTATION_XY}" 172 | 173 | # resets to window-level based on 5th, 95th percentiles over volume: 174 | min, max = self.cil_viewer.getImageMapRange((5., 95.), "scalar") 175 | window, level = self.cil_viewer.getSliceWindowLevelFromRange(min, max) 176 | if not self.window_level_sliders_are_percentages: 177 | state["slice_window_range"] = (min, max) 178 | state["slice_window"] = window 179 | state["slice_level"] = level 180 | else: 181 | state["slice_window_percentiles"] = (5., 95.) 182 | state["slice_window_as_percentage"] = self.convert_value_to_percentage(window) 183 | state["slice_level_as_percentage"] = self.convert_value_to_percentage(level) 184 | 185 | state["toggle_tracing"] = False 186 | state["toggle_interpolation"] = False 187 | self.cil_viewer.updatePipeline() 188 | self.html_view.update() 189 | self.layout.flush_content() 190 | 191 | def change_slice_number(self, slice_number): 192 | self.cil_viewer.setActiveSlice(slice_number) 193 | self.cil_viewer.updatePipeline() 194 | self.html_view.update() 195 | -------------------------------------------------------------------------------- /Wrappers/Python/ccpi/web_viewer/web_app.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2022 STFC, United Kingdom Research and Innovation 3 | # 4 | # Author 2022 Samuel Jones 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # http://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | import getopt 19 | import os 20 | import sys 21 | 22 | from trame.app import get_server 23 | 24 | from ccpi.web_viewer.trame_viewer2D import TrameViewer2D 25 | from ccpi.web_viewer.trame_viewer3D import TrameViewer3D 26 | 27 | server = get_server() 28 | state, ctrl = server.state, server.controller 29 | 30 | TRAME_VIEWER = None 31 | VIEWER_2D = False 32 | 33 | 34 | def reset_viewer2d(): 35 | set_viewer2d(False) 36 | 37 | 38 | def set_viewer2d(new_value): 39 | global VIEWER_2D 40 | VIEWER_2D = new_value 41 | 42 | 43 | def arg_parser(): 44 | """ 45 | Parse the passed arguments to the current 46 | :return: 47 | """ 48 | help_string = "web_app.py [optional args: -h, -d] \n" \ 49 | "Args:\n" \ 50 | "-h: Show this help and exit the program\n" \ 51 | "-d, --2D: Use the 2D viewer instead of the 3D viewer, the default is to just use the 3D viewer." 52 | try: 53 | opts, args = getopt.getopt(sys.argv[1:], "hd", ["2D"]) 54 | except getopt.GetoptError: 55 | print(help_string) 56 | sys.exit(2) 57 | for opt, arg in opts: 58 | if opt == '-h': 59 | print(help_string) 60 | elif opt in ("-d", "--2D"): 61 | global VIEWER_2D 62 | VIEWER_2D = True 63 | return data_finder() 64 | 65 | 66 | def data_finder(): 67 | """ 68 | Finds all files that are needed to be passed to the TrameViewer that in a list that is digestible, using the passed args. 69 | :return: list, of full file paths of data in the given directory parameter 70 | """ 71 | data_files = [] 72 | for index, arg in enumerate(sys.argv): 73 | if index == 0 or arg[0] == '-': 74 | # this is the python script in index 0 and not a passed arg 75 | continue 76 | if os.path.isfile(arg): 77 | data_files.append(arg) 78 | elif os.path.isdir(arg): 79 | files_in_dir = os.listdir(arg) 80 | for file in files_in_dir: 81 | data_files.append(os.path.join(arg, file)) 82 | else: 83 | print(f"This arg: {arg} is not a valid file or directory. Assuming it is for trame.") 84 | return data_files 85 | 86 | 87 | def main() -> int: 88 | """ 89 | Create the main class and run the TrameViewer 90 | :return: int, exit code for the program 91 | """ 92 | data_files = arg_parser() 93 | global TRAME_VIEWER 94 | if not VIEWER_2D: 95 | TRAME_VIEWER = TrameViewer3D(data_files) 96 | TRAME_VIEWER.start() 97 | else: 98 | TRAME_VIEWER = TrameViewer2D(data_files) 99 | TRAME_VIEWER.start() 100 | return 0 101 | 102 | 103 | @state.change("slice") 104 | def update_slice(**kwargs): 105 | TRAME_VIEWER.change_slice_number(kwargs["slice"]) 106 | 107 | 108 | @state.change("orientation") 109 | def change_orientation(**kwargs): 110 | if "orientation" in kwargs: 111 | orientation = kwargs["orientation"] 112 | if orientation is not int: 113 | orientation = int(orientation) 114 | TRAME_VIEWER.switch_to_orientation(orientation) 115 | 116 | 117 | @state.change("opacity") 118 | def change_opacity_mapping(**kwargs): 119 | if "opacity" in kwargs: 120 | TRAME_VIEWER.set_opacity_mapping(kwargs["opacity"]) 121 | 122 | 123 | @state.change("file_name") 124 | def change_model(**kwargs): 125 | TRAME_VIEWER.load_file(kwargs['file_name'], kwargs.get('opacity', "scalar")) 126 | 127 | 128 | @state.change("color_map") 129 | def change_color_map(**kwargs): 130 | TRAME_VIEWER.change_color_map(kwargs['color_map']) 131 | 132 | 133 | @state.change("windowing") 134 | def change_windowing(**kwargs): 135 | TRAME_VIEWER.change_windowing(kwargs["windowing"][0], kwargs["windowing"][1], windowing_method=kwargs["opacity"]) 136 | 137 | 138 | @state.change("coloring") 139 | def change_coloring(**kwargs): 140 | TRAME_VIEWER.change_coloring(kwargs["coloring"][0], kwargs["coloring"][1]) 141 | 142 | 143 | @state.change("slice_window") 144 | def change_slice_window(**kwargs): 145 | TRAME_VIEWER.change_slice_window(kwargs["slice_window"]) 146 | 147 | 148 | @state.change("slice_window_as_percentage") 149 | def change_slice_window_as_percentage(**kwargs): 150 | TRAME_VIEWER.change_slice_window_as_percentage(kwargs["slice_window_as_percentage"]) 151 | 152 | 153 | @state.change("slice_level") 154 | def change_slice_level(**kwargs): 155 | TRAME_VIEWER.change_slice_level(kwargs["slice_level"]) 156 | 157 | 158 | @state.change("slice_level_as_percentage") 159 | def change_slice_level_as_percentage(**kwargs): 160 | TRAME_VIEWER.change_slice_level_as_percentage(kwargs["slice_level_as_percentage"]) 161 | 162 | 163 | @state.change("slice_window_range") 164 | def change_slice_window_level_range(**kwargs): 165 | min_window = kwargs["slice_window_range"][0] 166 | max_window = kwargs["slice_window_range"][1] 167 | TRAME_VIEWER.change_slice_window_level_range(min_window, max_window) 168 | 169 | 170 | @state.change("slice_window_percentiles") 171 | def change_slice_window_level_percentiles(**kwargs): 172 | min_window = kwargs["slice_window_percentiles"][0] 173 | max_window = kwargs["slice_window_percentiles"][1] 174 | TRAME_VIEWER.change_slice_window_level_percentiles(min_window, max_window) 175 | 176 | 177 | @state.change("slice_detailed_sliders") 178 | def change_slice_detailed_sliders(**kwargs): 179 | TRAME_VIEWER.change_window_level_detail_sliders(kwargs["slice_detailed_sliders"]) 180 | 181 | 182 | @state.change("slice_visibility") 183 | def change_slice_visibility(**kwargs): 184 | TRAME_VIEWER.change_slice_visibility(kwargs["slice_visibility"]) 185 | 186 | 187 | @state.change("volume_visibility") 188 | def change_volume_visibility(**kwargs): 189 | TRAME_VIEWER.change_volume_visibility(kwargs["volume_visibility"]) 190 | 191 | 192 | @state.change("background_color") 193 | def change_background_color(**kwargs): 194 | TRAME_VIEWER.change_background_color(kwargs["background_color"]) 195 | 196 | 197 | @state.change("toggle_clipping") 198 | def change_clipping(**kwargs): 199 | TRAME_VIEWER.change_clipping(kwargs["toggle_clipping"]) 200 | 201 | 202 | @state.change("toggle_tracing") 203 | def change_tracing(**kwargs): 204 | TRAME_VIEWER.change_tracing(kwargs["toggle_tracing"]) 205 | 206 | 207 | @state.change("toggle_profiling") 208 | def change_profiling(**kwargs): 209 | TRAME_VIEWER.change_profiling(kwargs["toggle_profiling"]) 210 | 211 | 212 | @state.change("toggle_interpolation") 213 | def change_interpolation(**kwargs): 214 | TRAME_VIEWER.change_interpolation(kwargs["toggle_interpolation"]) 215 | 216 | 217 | @state.change("show_slice_histogram") 218 | def show_slice_histogram(**kwargs): 219 | TRAME_VIEWER.show_slice_histogram(kwargs["show_slice_histogram"]) 220 | 221 | 222 | if __name__ == "__main__": 223 | sys.exit(main()) 224 | -------------------------------------------------------------------------------- /Wrappers/Python/conda-recipe/bld.bat: -------------------------------------------------------------------------------- 1 | 2 | cd %RECIPE_DIR%/.. 3 | 4 | pip install . 5 | if errorlevel 1 exit 1 6 | -------------------------------------------------------------------------------- /Wrappers/Python/conda-recipe/build.sh: -------------------------------------------------------------------------------- 1 | cd $RECIPE_DIR/.. 2 | 3 | pip install . 4 | 5 | 6 | -------------------------------------------------------------------------------- /Wrappers/Python/conda-recipe/environment.yml: -------------------------------------------------------------------------------- 1 | name: cilviewer 2 | channels: 3 | - ccpi 4 | - conda-forge 5 | dependencies: 6 | - python 7 | - numpy 8 | - vtk 9 | - importlib_metadata # [py<38] 10 | - h5py 11 | - pillow 12 | - pyyaml 13 | - schema 14 | -------------------------------------------------------------------------------- /Wrappers/Python/conda-recipe/meta.yaml: -------------------------------------------------------------------------------- 1 | package: 2 | name: ccpi-viewer 3 | version: {{ environ.get('GIT_DESCRIBE_TAG','v')[1:] }} 4 | 5 | source: 6 | path: ../../../ 7 | 8 | build: 9 | skip: True # [py==38 and np==115] 10 | preserve_egg_dir: False 11 | number: {{ GIT_DESCRIBE_NUMBER }} 12 | noarch: python 13 | entry_points: 14 | - resample = ccpi.viewer.cli.resample:main 15 | - web_cilviewer = ccpi.web_viewer.web_app:main 16 | - cilviewer = ccpi.viewer.standalone_viewer:main 17 | 18 | test: 19 | requires: 20 | - cil-data=22.0.0 21 | - pillow 22 | - pytest 23 | - pyside2 24 | - eqt>=1.0.0 25 | source_files: 26 | - ./Wrappers/Python/test 27 | 28 | commands: 29 | - python -m pytest Wrappers/Python/test # [not win] 30 | 31 | requirements: 32 | build: 33 | - python >=3.7 34 | - vtk 35 | - setuptools 36 | 37 | run: 38 | - python >=3.7 39 | - numpy 40 | - vtk 41 | - importlib_metadata # [py<38] 42 | - h5py 43 | - pyyaml 44 | - schema 45 | 46 | about: 47 | home: http://www.ccpi.ac.uk 48 | license: Apache v.2.0 license 49 | summary: 'CCPi Core Imaging Library (Viewer)' 50 | -------------------------------------------------------------------------------- /Wrappers/Python/conda-recipe/ui_env.yml: -------------------------------------------------------------------------------- 1 | name: cilviewer 2 | channels: 3 | - ccpi 4 | - conda-forge 5 | dependencies: 6 | - pyside2 7 | - eqt>=1.0.0 8 | - cil-data=22.0.0 9 | 10 | -------------------------------------------------------------------------------- /Wrappers/Python/examples/2Dimage_reading.py: -------------------------------------------------------------------------------- 1 | from ccpi.viewer.CILViewer2D import CILViewer2D 2 | import vtk 3 | 4 | # This example imports a 2D tiff image and tests the 2D viewer 5 | DATASET_TO_READ = [path] # insert path here 6 | if DATASET_TO_READ is not None: 7 | reader = vtk.vtkTIFFReader() 8 | reader.SetFileName(DATASET_TO_READ) 9 | reader.Update() 10 | 11 | v = CILViewer2D() 12 | print(reader.GetOutput()) 13 | v.setInputData(reader.GetOutput()) 14 | v.startRenderLoop() 15 | -------------------------------------------------------------------------------- /Wrappers/Python/examples/error_observer.py: -------------------------------------------------------------------------------- 1 | import vtk 2 | from ccpi.viewer.iviewer import iviewer 3 | from ccpi.viewer.utils.conversion import cilHDF5ResampleReader 4 | from ccpi.viewer.utils.error_handling import EndObserver, ErrorObserver 5 | ''' 6 | An example which shows attaching an error observer and end observer 7 | to reading a HDF5 file 8 | ''' 9 | 10 | if __name__ == "__main__": 11 | hdf5_name = r'C:\Users\lhe97136\Work\Data\24737_fd_normalised.nxs' 12 | readerhdf5 = cilHDF5ResampleReader() 13 | ''' Create the error observer and attach it to the hdf5 reader. If an error occurs, 14 | it will run the callback_fn, which is given the error message as an input. 15 | So in this case it will print the error message.''' 16 | error_obs = ErrorObserver(callback_fn=print) 17 | readerhdf5.AddObserver(vtk.vtkCommand.ErrorEvent, error_obs) 18 | ''' Create the end observer and attach it to the hdf5 reader. 19 | When the function it calls finishes, it checks the given error observer to see if an error occurred. 20 | If not, it then runs the callback_fn. In this case, it displays the hdf5 image in the iviewer.''' 21 | end_obs = EndObserver( 22 | error_observer=error_obs, 23 | callback_fn=lambda: iviewer(readerhdf5.GetOutputDataObject(0), readerhdf5.GetOutputDataObject(0))) 24 | readerhdf5.AddObserver(vtk.vtkCommand.EndEvent, end_obs) 25 | readerhdf5.SetFileName(hdf5_name) 26 | ''' With this commented, we get an exception due to DatasetName not being set. 27 | Uncommenting this runs the callback_fn in the EndEvent: ''' 28 | #readerhdf5.SetDatasetName("entry1/tomo_entry/data/data") 29 | readerhdf5.Update() 30 | -------------------------------------------------------------------------------- /Wrappers/Python/examples/image_reader_and_writer.py: -------------------------------------------------------------------------------- 1 | '''' 2 | This example: 3 | 1. Writes an example dataset to a file: example_dataset.npy 4 | 2. Reads and resamples the dataset. 5 | 3. Writes out this resampled dataset and details about the original dataset to resampled_dataset.hdf5 6 | 4. prints the structure of this hdf5 file we have written out 7 | 5. Reads the resampled_dataset.hdf5 8 | 6. Writes out the resampled dataset from step 2 to a metaimage file: resampled_dataset_2.mha using the ImageWriter 9 | 7. Reads the resampled_dataset_2.mha 10 | 8. Displays the resulting datasets on the viewer. 11 | 12 | Note: to test if it works with a real dataset, set DATASET_TO_READ to a filepath containing 13 | a dataset, this will skip step 1 and read in your dataset instead. 14 | 15 | ''' 16 | 17 | from ccpi.viewer.utils.io import ImageReader 18 | from ccpi.viewer.utils.io import cilviewerHDF5Reader, cilviewerHDF5Writer, ImageWriter 19 | import h5py 20 | from ccpi.viewer.iviewer import iviewer 21 | import numpy as np 22 | import vtk 23 | 24 | DATASET_TO_READ = None 25 | TARGET_SIZE = (100)**3 26 | FILE_TO_WRITE = 'resampled_dataset.hdf5' 27 | LOG_FILE = 'image_reader_and_writer.log' 28 | SECOND_FILE_TO_WRITE = 'resampled_dataset_2.mha' 29 | 30 | 31 | # --- UTILS --------------------------------- 32 | def descend_hdf5_obj(obj, sep='\t'): 33 | """ 34 | Iterates through the groups in a HDF5 file (obj) 35 | and prints the groups and datasets names and 36 | datasets attributes 37 | """ 38 | if type(obj) in [h5py._hl.group.Group, h5py._hl.files.File]: 39 | for key in obj.keys(): 40 | print(sep, '-', key, ':', obj[key]) 41 | descend_hdf5_obj(obj[key], sep=sep + '\t') 42 | elif type(obj) == h5py._hl.dataset.Dataset: 43 | for key in obj.attrs.keys(): 44 | print(sep + '\t', '-', key, ':', obj.attrs[key]) 45 | 46 | 47 | def print_hdf5_metadata(path, group='/'): 48 | """ 49 | print HDF5 file metadata 50 | path: (str) 51 | path of hdf5 file 52 | group: (str), default: '/' 53 | a specific group to print the metadata for, 54 | defaults to the root group 55 | """ 56 | with h5py.File(path, 'r') as f: 57 | descend_hdf5_obj(f[group]) 58 | 59 | 60 | # ----- STEP 1 ------------------------------ 61 | 62 | if DATASET_TO_READ is None: 63 | input_3D_array = np.random.random(size=(500, 100, 600)) 64 | # write to NUMPY: ---------- 65 | DATASET_TO_READ = 'test_3D_data.npy' 66 | np.save(DATASET_TO_READ, input_3D_array) 67 | 68 | # ----- STEP 2 ------------------------------ 69 | reader = ImageReader(file_name=DATASET_TO_READ, target_size=TARGET_SIZE, resample_z=True, log_file=LOG_FILE) 70 | resampled_image = reader.Read() 71 | 72 | resampled_image_attrs = reader.GetLoadedImageAttrs() 73 | original_image_attrs = reader.GetOriginalImageAttrs() 74 | 75 | # ----- STEP 3 ------------------------------ 76 | writer = cilviewerHDF5Writer() 77 | writer.SetFileName(FILE_TO_WRITE) 78 | # format='hdf5' 79 | writer.SetOriginalDataset(None, original_image_attrs) 80 | writer.AddChildDataset(resampled_image, resampled_image_attrs) 81 | writer.Write() 82 | 83 | # ---- STEP 4 -------------------------------- 84 | print_hdf5_metadata(FILE_TO_WRITE) 85 | 86 | # ---- STEP 5 -------------------------------- 87 | reader = cilviewerHDF5Reader() 88 | reader.SetFileName(FILE_TO_WRITE) 89 | reader.Update() 90 | read_resampled_image = reader.GetOutputDataObject(0) 91 | 92 | print(read_resampled_image.GetOrigin()) 93 | print(read_resampled_image.GetSpacing()) 94 | 95 | # ---- STEP 6 -------------------------------- 96 | writer = ImageWriter() 97 | writer.SetFileName(SECOND_FILE_TO_WRITE) 98 | writer.SetFileFormat('mha') 99 | writer.AddChildDataset(resampled_image) 100 | writer.Write() 101 | 102 | # ---- STEP 7 -------------------------------- 103 | reader = vtk.vtkMetaImageReader() 104 | reader.SetFileName(SECOND_FILE_TO_WRITE) 105 | reader.Update() 106 | read_resampled_image2 = reader.GetOutputDataObject(0) 107 | 108 | print(read_resampled_image2.GetOrigin()) 109 | print(read_resampled_image2.GetSpacing()) 110 | 111 | # ---- STEP 8 -------------------------------- 112 | iviewer(read_resampled_image, read_resampled_image2) 113 | -------------------------------------------------------------------------------- /Wrappers/Python/examples/read_and_resample.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import vtk 3 | import os 4 | from ccpi.viewer import viewer2D 5 | from ccpi.viewer.utils.conversion import Converter, parseNpyHeader, cilNumpyMETAImageWriter 6 | from tqdm import tqdm 7 | from vtk.util.vtkAlgorithm import VTKPythonAlgorithmBase 8 | import functools 9 | import tempfile 10 | from ccpi.viewer.utils.conversion import cilNumpyResampleReader 11 | 12 | if __name__ == "__main__": 13 | fname = os.path.abspath(r"E:\Documents\Dataset\CCPi\DVC\f000_crop\frame_000_f.npy") 14 | 15 | def progress(x, y): 16 | print("{:.0f}%".format(100 * x.GetProgress())) 17 | 18 | reader = cilNumpyResampleReader() 19 | reader.SetFileName(fname) 20 | reader.SetTargetSize(512**3) 21 | reader.AddObserver(vtk.vtkCommand.ProgressEvent, progress) 22 | reader.Update() 23 | resampled_image = reader.GetOutput() 24 | print("Spacing ", resampled_image.GetSpacing()) 25 | 26 | v = viewer2D() 27 | v.setInputData(resampled_image) 28 | 29 | v.sliceActor.SetInterpolate(True) 30 | 31 | print("interpolated?", v.sliceActor.GetInterpolate()) 32 | v.startRenderLoop() 33 | -------------------------------------------------------------------------------- /Wrappers/Python/examples/rectilinearwipe.py: -------------------------------------------------------------------------------- 1 | # Example of RectilinearWipe in CILViewer 2 | # freely based on https://kitware.github.io/vtk-examples/site/Cxx/Widgets/RectilinearWipeWidget/ 3 | # Author Edoardo Pasca 2021 4 | 5 | import vtk 6 | import numpy 7 | import os 8 | from ccpi.viewer.utils.conversion import Converter 9 | from vtk.util import numpy_support, vtkImageImportFromArray 10 | 11 | # Utility functions to transform numpy arrays to vtkImageData and viceversa 12 | 13 | 14 | def numpy2vtkImporter(nparray, spacing=(1., 1., 1.), origin=(0, 0, 0), transpose=[2, 1, 0]): 15 | '''Creates a vtkImageImportFromArray object and returns it. 16 | 17 | It handles the different axis order from numpy to VTK''' 18 | importer = vtkImageImportFromArray.vtkImageImportFromArray() 19 | importer.SetArray(numpy.transpose(nparray, transpose).copy()) 20 | importer.SetDataSpacing(spacing) 21 | importer.SetDataOrigin(origin) 22 | return importer 23 | 24 | 25 | ren = vtk.vtkRenderer() 26 | renWin = vtk.vtkRenderWindow() 27 | renWin.AddRenderer(ren) 28 | interactor = vtk.vtkRenderWindowInteractor() 29 | interactor.SetRenderWindow(renWin) 30 | 31 | # here we load the whole dataset. It may be possible to read only part of it? 32 | data1 = numpy.load(os.path.abspath("C:/Users/ofn77899/Data/dvc/frame_000_f.npy")) 33 | data2 = numpy.load(os.path.abspath("C:/Users/ofn77899/Data/dvc/frame_010_f.npy")) 34 | img1 = Converter.numpy2vtkImage(data1, deep=0) 35 | img2 = Converter.numpy2vtkImage(data2, deep=1) 36 | 37 | reader1 = vtk.vtkExtractVOI() 38 | reader2 = vtk.vtkExtractVOI() 39 | 40 | reader1.SetInputData(img1) 41 | reader2.SetInputData(img2) 42 | 43 | extent1 = list(img1.GetExtent()) 44 | extent2 = list(img2.GetExtent()) 45 | #extent is slice number N 46 | N1 = 512 47 | extent1[4] = N1 48 | extent1[5] = N1 49 | extent2[4] = N1 50 | extent2[5] = N1 51 | 52 | reader1.SetVOI(*extent1) 53 | reader2.SetVOI(*extent2) 54 | # img1 = numpy.expand_dims(data[512], axis=0) 55 | 56 | # reader1 = numpy2vtkImporter(img1, transpose=[0,1,2]) 57 | # # img1 = Converter.numpy2vtkImage(tmp, deep=1) 58 | 59 | # img2 = numpy.expand_dims(data[612], axis=0) 60 | # reader2 = numpy2vtkImporter(img2, transpose=[0,1,2]) 61 | # img2 = Converter.numpy2vtkImage(tmp, deep=1) 62 | 63 | wipe = vtk.vtkImageRectilinearWipe() 64 | wipe.SetInputConnection(0, reader1.GetOutputPort()) 65 | wipe.SetInputConnection(1, reader2.GetOutputPort()) 66 | wipe.SetPosition(256, 256) 67 | wipe.SetWipe(0) 68 | 69 | wipeActor = vtk.vtkImageActor() 70 | wipeActor.GetMapper().SetInputConnection(wipe.GetOutputPort()) 71 | 72 | wipeWidget = vtk.vtkRectilinearWipeWidget() 73 | wipeWidget.SetInteractor(interactor) 74 | 75 | wipeWidgetRep = wipeWidget.GetRepresentation() 76 | wipeWidgetRep.SetImageActor(wipeActor) 77 | wipeWidgetRep.SetRectilinearWipe(wipe) 78 | wipeWidgetRep.GetProperty().SetLineWidth(2.0) 79 | wipeWidgetRep.GetProperty().SetOpacity(0.75) 80 | 81 | ren.AddActor(wipeActor) 82 | renWin.SetSize(512, 512) 83 | 84 | renWin.Render() 85 | wipeWidget.On() 86 | interactor.Start() 87 | -------------------------------------------------------------------------------- /Wrappers/Python/examples/ui_examples/BoxWidgetAroundSlice.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from PySide2 import QtWidgets 3 | from ccpi.viewer import viewer2D, viewer3D 4 | from SingleViewerCentralWidget import SingleViewerCenterWidget 5 | from ccpi.viewer.widgets import cilviewerBoxWidget, cilviewerLineWidget 6 | 7 | app = QtWidgets.QApplication(sys.argv) 8 | # can change the behaviour by setting which viewer you want 9 | # between viewer2D and viewer3D 10 | window = SingleViewerCenterWidget(viewer=viewer2D) 11 | line_widget = cilviewerLineWidget.CreateAtCoordOnXYPlane(window.frame.viewer, 'x', 5) 12 | line_widget.On() 13 | box_widget = cilviewerBoxWidget.CreateAroundSliceOnXYPlane(window.frame.viewer, 14 | 'y', 15 | 10, 16 | width=1, 17 | outline_color=(0, 0, 1)) 18 | box_widget.On() 19 | window.frame.viewer.updatePipeline() 20 | 21 | sys.exit(app.exec_()) 22 | -------------------------------------------------------------------------------- /Wrappers/Python/examples/ui_examples/FourDockableLinkedViewerWidgets.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import vtk 3 | import os 4 | from PySide2 import QtCore, QtWidgets 5 | from vtk.qt.QVTKRenderWindowInteractor import QVTKRenderWindowInteractor 6 | from ccpi.viewer.QCILRenderWindowInteractor import QCILRenderWindowInteractor 7 | from ccpi.viewer import viewer2D, viewer3D 8 | from ccpi.viewer.QCILViewerWidget import QCILViewerWidget, QCILDockableWidget 9 | # Import linking class to join 2D and 3D viewers 10 | import ccpi.viewer.viewerLinker as vlink 11 | from ccpi.viewer.utils import example_data 12 | 13 | 14 | class FourLinkedViewersDockableWidget(QtWidgets.QMainWindow): 15 | 16 | def __init__(self, parent=None, reader=None): 17 | QtWidgets.QMainWindow.__init__(self, parent) 18 | #self.resize(800,600) 19 | 20 | # create the dockable widgets with the viewer inside 21 | self.v00 = QCILDockableWidget(parent, 22 | viewer=viewer2D, 23 | shape=(600, 600), 24 | title="X", 25 | interactorStyle=vlink.Linked2DInteractorStyle) 26 | self.v01 = QCILDockableWidget(viewer=viewer2D, 27 | shape=(600, 600), 28 | title="Y", 29 | interactorStyle=vlink.Linked2DInteractorStyle) 30 | self.v10 = QCILDockableWidget(viewer=viewer2D, 31 | shape=(600, 600), 32 | title="Z", 33 | interactorStyle=vlink.Linked2DInteractorStyle) 34 | self.v11 = QCILDockableWidget(viewer=viewer3D, 35 | shape=(600, 600), 36 | title="3D", 37 | interactorStyle=vlink.Linked3DInteractorStyle) 38 | 39 | # Create the viewer linkers 40 | viewerLinkers = self.linkedViewersSetup(self.v00, self.v01, self.v10, self.v11) 41 | 42 | for linker in viewerLinkers: 43 | linker.enable() 44 | 45 | self.viewerLinkers = viewerLinkers 46 | 47 | head = example_data.HEAD.get() 48 | 49 | for el in [self.v00, self.v01, self.v10, self.v11]: 50 | el.viewer.setInputData(head) 51 | # set slice orientation 52 | self.v00.viewer.setSliceOrientation('x') 53 | self.v01.viewer.setSliceOrientation('y') 54 | self.v10.viewer.setSliceOrientation('z') 55 | # disable reslicing by the user 56 | self.v00.viewer.style.reslicing_enabled = False 57 | self.v01.viewer.style.reslicing_enabled = False 58 | self.v10.viewer.style.reslicing_enabled = False 59 | 60 | # add to the GUI 61 | 62 | self.addDockWidget(QtCore.Qt.LeftDockWidgetArea, self.v00, QtCore.Qt.Vertical) 63 | self.addDockWidget(QtCore.Qt.LeftDockWidgetArea, self.v01, QtCore.Qt.Vertical) 64 | 65 | self.addDockWidget(QtCore.Qt.RightDockWidgetArea, self.v10, QtCore.Qt.Vertical) 66 | self.addDockWidget(QtCore.Qt.RightDockWidgetArea, self.v11, QtCore.Qt.Vertical) 67 | 68 | self.show() 69 | 70 | def linkedViewersSetup(self, *args): 71 | linked = [viewer for viewer in args] 72 | # link all combination of viewers 73 | #pairs = [(linked[0],linked[i]) for i in range(1, len(linked))] 74 | pairs = [] 75 | for i in range(len(linked)): 76 | for j in range(len(linked)): 77 | if not i == j: 78 | pairs.append((linked[i], linked[j])) 79 | 80 | linkers = [] 81 | for pair in pairs: 82 | v2d = pair[0].viewer 83 | v3d = pair[1].viewer 84 | link2D3D = vlink.ViewerLinker(v2d, v3d) 85 | link2D3D.setLinkPan(False) 86 | link2D3D.setLinkZoom(False) 87 | link2D3D.setLinkWindowLevel(True) 88 | link2D3D.setLinkSlice(False) 89 | linkers.append(link2D3D) 90 | return linkers 91 | 92 | 93 | if __name__ == "__main__": 94 | err = vtk.vtkFileOutputWindow() 95 | err.SetFileName("viewer.log") 96 | vtk.vtkOutputWindow.SetInstance(err) 97 | 98 | app = QtWidgets.QApplication(sys.argv) 99 | 100 | reader = vtk.vtkNIFTIImageReader() 101 | data_dir = os.path.abspath( 102 | 'C:/Users/ofn77899/Documents/Projects/PETMR/Publications/2020RS_MCIR/cluster_test/recons') 103 | 104 | reader.SetFileName( 105 | os.path.join( 106 | data_dir, 107 | 'ungated_Reg-FGP_TV-alpha5.0_nGates1_nSubsets1_pdhg_wPrecond_gamma1.0_wAC_wNorm_wRands-riters100_noMotion_iters_39.nii' 108 | )) 109 | window = FourLinkedViewersDockableWidget(reader=None) 110 | 111 | sys.exit(app.exec_()) 112 | -------------------------------------------------------------------------------- /Wrappers/Python/examples/ui_examples/SingleViewerCentralWidget.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import vtk 3 | from PySide2 import QtCore, QtWidgets 4 | from ccpi.viewer import viewer2D, viewer3D 5 | from ccpi.viewer.QCILViewerWidget import QCILViewerWidget 6 | from ccpi.viewer.utils import example_data 7 | 8 | 9 | class SingleViewerCenterWidget(QtWidgets.QMainWindow): 10 | 11 | def __init__(self, parent=None, viewer=viewer2D): 12 | QtWidgets.QMainWindow.__init__(self, parent) 13 | 14 | self.frame = QCILViewerWidget(parent, viewer=viewer, shape=(600, 600)) 15 | 16 | head = example_data.HEAD.get() 17 | 18 | self.frame.viewer.setInputData(head) 19 | 20 | self.setCentralWidget(self.frame) 21 | 22 | self.show() 23 | 24 | 25 | if __name__ == "__main__": 26 | 27 | app = QtWidgets.QApplication(sys.argv) 28 | # can change the behaviour by setting which viewer you want 29 | # between viewer2D and viewer3D 30 | window = SingleViewerCenterWidget(viewer=viewer3D) 31 | 32 | sys.exit(app.exec_()) 33 | -------------------------------------------------------------------------------- /Wrappers/Python/examples/ui_examples/SingleViewerCentralWidget2.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import vtk 3 | from PySide2 import QtCore, QtWidgets 4 | from ccpi.viewer import viewer2D, viewer3D 5 | from ccpi.viewer.QCILViewerWidget import QCILViewerWidget 6 | import os 7 | from ccpi.viewer.utils import example_data 8 | 9 | 10 | class SingleViewerCenterWidget(QtWidgets.QMainWindow): 11 | 12 | def __init__(self, parent=None, viewer=viewer2D): 13 | QtWidgets.QMainWindow.__init__(self, parent) 14 | self.setGeometry(450, 250, 1000, 1000) 15 | self.frame = QCILViewerWidget(parent, viewer=viewer, shape=(2000, 2000)) 16 | 17 | head = example_data.HEAD.get() 18 | 19 | self.frame.viewer.setInputData(head) 20 | self.frame.viewer.setInputData2(head) 21 | 22 | self.setCentralWidget(self.frame) 23 | 24 | self.show() 25 | 26 | 27 | if __name__ == "__main__": 28 | 29 | app = QtWidgets.QApplication(sys.argv) 30 | # can change the behaviour by setting which viewer you want 31 | # between viewer2D and viewer3D 32 | window = SingleViewerCenterWidget(viewer=viewer2D) 33 | 34 | sys.exit(app.exec_()) 35 | -------------------------------------------------------------------------------- /Wrappers/Python/examples/ui_examples/TwoLinkedViewersCentralWidget.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import vtk 3 | from PySide2 import QtCore, QtWidgets 4 | from vtk.qt.QVTKRenderWindowInteractor import QVTKRenderWindowInteractor 5 | from ccpi.viewer.QCILRenderWindowInteractor import QCILRenderWindowInteractor 6 | from ccpi.viewer import viewer2D, viewer3D 7 | from ccpi.viewer.QCILViewerWidget import QCILViewerWidget 8 | # Import linking class to join 2D and 3D viewers 9 | import ccpi.viewer.viewerLinker as vlink 10 | from ccpi.viewer.utils import example_data 11 | 12 | 13 | class TwoLinkedViewersCenterWidget(QtWidgets.QMainWindow): 14 | 15 | def __init__(self, parent=None): 16 | QtWidgets.QMainWindow.__init__(self, parent) 17 | #self.resize(800,600) 18 | 19 | self.frame1 = QCILViewerWidget(parent, 20 | viewer=viewer2D, 21 | shape=(600, 600), 22 | interactorStyle=vlink.Linked2DInteractorStyle) 23 | self.frame2 = QCILViewerWidget(parent, 24 | viewer=viewer3D, 25 | shape=(600, 600), 26 | interactorStyle=vlink.Linked3DInteractorStyle) 27 | 28 | head = example_data.HEAD.get() 29 | 30 | self.frame1.viewer.setInputData(head) 31 | self.frame2.viewer.setInputData(head) 32 | 33 | # Initially link viewers 34 | self.linkedViewersSetup() 35 | self.link2D3D.enable() 36 | 37 | layout = QtWidgets.QBoxLayout(QtWidgets.QBoxLayout.LeftToRight) 38 | layout.addWidget(self.frame1) 39 | layout.addWidget(self.frame2) 40 | 41 | cw = QtWidgets.QWidget() 42 | cw.setLayout(layout) 43 | self.setCentralWidget(cw) 44 | self.central_widget = cw 45 | self.show() 46 | 47 | def linkedViewersSetup(self): 48 | v2d = self.frame1.viewer 49 | v3d = self.frame2.viewer 50 | self.link2D3D = vlink.ViewerLinker(v2d, v3d) 51 | self.link2D3D.setLinkPan(False) 52 | self.link2D3D.setLinkZoom(False) 53 | self.link2D3D.setLinkWindowLevel(True) 54 | self.link2D3D.setLinkSlice(True) 55 | 56 | 57 | if __name__ == "__main__": 58 | 59 | app = QtWidgets.QApplication(sys.argv) 60 | 61 | #window = MainWindow3() 62 | window = TwoLinkedViewersCenterWidget() 63 | 64 | sys.exit(app.exec_()) 65 | -------------------------------------------------------------------------------- /Wrappers/Python/examples/ui_examples/opacity_in_viewer.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import vtk 4 | from ccpi.viewer import viewer3D 5 | from ccpi.viewer.iviewer import SingleViewerCenterWidget 6 | from ccpi.viewer.QCILViewerWidget import QCILViewerWidget 7 | from eqt.ui.UIFormWidget import FormDockWidget 8 | from PySide2 import QtWidgets 9 | from PySide2.QtCore import Qt 10 | from PySide2.QtWidgets import QComboBox 11 | from ccpi.viewer.utils import example_data 12 | 13 | try: 14 | import qdarkstyle 15 | from qdarkstyle.dark.palette import DarkPalette 16 | set_style = True 17 | except: 18 | set_style = False 19 | 20 | 21 | class OpacityViewerWidget(SingleViewerCenterWidget): 22 | 23 | def __init__(self, parent=None, viewer=viewer3D): 24 | SingleViewerCenterWidget.__init__(self, parent) 25 | 26 | self.frame = QCILViewerWidget(parent, viewer=viewer, shape=(600, 600)) 27 | 28 | self.setCentralWidget(self.frame) 29 | 30 | self.create_settings_dockwidget() 31 | 32 | if set_style: 33 | self.set_app_style() 34 | 35 | self.show() 36 | 37 | def set_input(self, data): 38 | self.frame.viewer.setInputData(data) 39 | 40 | def create_settings_dockwidget(self): 41 | form_dock_widget = FormDockWidget(title='') 42 | drop_down = QComboBox() 43 | drop_down.addItems(['gradient', 'scalar']) 44 | drop_down.currentTextChanged.connect( 45 | lambda: self.frame.viewer.setVolumeRenderOpacityMethod(drop_down.currentText())) 46 | form_dock_widget.addWidget(drop_down, "Select Opacity Method:", 'select_opacity') 47 | self.addDockWidget(Qt.TopDockWidgetArea, form_dock_widget) 48 | 49 | def set_app_style(self): 50 | '''Sets app stylesheet ''' 51 | style = qdarkstyle.load_stylesheet(palette=DarkPalette) 52 | self.setStyleSheet(style) 53 | 54 | 55 | class viewer_window(object): 56 | ''' 57 | a Qt interactive viewer with one single dataset 58 | Parameters 59 | ---------- 60 | data: vtkImageData 61 | image to be displayed 62 | ''' 63 | 64 | def __init__(self, data): 65 | '''Creator''' 66 | app = QtWidgets.QApplication(sys.argv) 67 | self.app = app 68 | 69 | self.setUp(data) 70 | self.show() 71 | 72 | def setUp(self, data): 73 | window = OpacityViewerWidget() 74 | window.set_input(data) 75 | self.window = window 76 | self.has_run = None 77 | 78 | def show(self): 79 | if self.has_run is None: 80 | self.has_run = self.app.exec_() 81 | else: 82 | print('No instance can be run interactively again. Delete and re-instantiate.') 83 | 84 | 85 | if __name__ == "__main__": 86 | 87 | err = vtk.vtkFileOutputWindow() 88 | err.SetFileName("viewer.log") 89 | vtk.vtkOutputWindow.SetInstance(err) 90 | 91 | # To use your own dataset: 92 | # reader = vtk.vtkMetaImageReader() 93 | # reader.SetFileName(r'head.mha') 94 | # reader.Update() 95 | # viewer_window(reader.GetOutput()) 96 | 97 | data = example_data.HEAD.get() 98 | viewer_window(data) 99 | -------------------------------------------------------------------------------- /Wrappers/Python/examples/viewer2D-without-qt.py: -------------------------------------------------------------------------------- 1 | from ccpi.viewer import viewer2D 2 | from ccpi.viewer.utils import example_data 3 | 4 | v = viewer2D() 5 | # Load head data 6 | data = example_data.HEAD.get() 7 | v.setInputData(data) 8 | v.startRenderLoop() 9 | -------------------------------------------------------------------------------- /Wrappers/Python/examples/viewer3D-without-qt.py: -------------------------------------------------------------------------------- 1 | from ccpi.viewer import viewer3D 2 | from ccpi.viewer.utils import example_data 3 | 4 | v = viewer3D() 5 | # Load head data 6 | data = example_data.HEAD.get() 7 | v.setInputData(data) 8 | v.startRenderLoop() -------------------------------------------------------------------------------- /Wrappers/Python/setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright 2017 Edoardo Pasca 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | 16 | from setuptools import setup 17 | import os 18 | import subprocess 19 | 20 | 21 | def version2pep440(version): 22 | """normalises the version from git describe to pep440 23 | 24 | https://www.python.org/dev/peps/pep-0440/#id29 25 | """ 26 | if version[0] == "v": 27 | version = version[1:] 28 | 29 | if u"-" in version: 30 | v = version.split("-") 31 | v_pep440 = "{}.dev{}".format(v[0], v[1]) 32 | else: 33 | v_pep440 = version 34 | 35 | return v_pep440 36 | 37 | 38 | git_version_string = subprocess.check_output("git describe", shell=True).decode("utf-8").rstrip()[1:] 39 | 40 | if os.environ.get("CONDA_BUILD", 0) == "1": 41 | cwd = os.path.join(os.environ.get("RECIPE_DIR"), "..") 42 | # requirements are processed by conda 43 | requires = [] 44 | else: 45 | requires = ["numpy", "vtk"] 46 | cwd = os.getcwd() 47 | 48 | version = version2pep440(git_version_string) 49 | 50 | # update the version string 51 | fname = os.path.join(cwd, "ccpi", "viewer", "version.py") 52 | 53 | if os.path.exists(fname): 54 | os.remove(fname) 55 | with open(fname, "w") as f: 56 | f.write("version = '{}'".format(version)) 57 | 58 | setup( 59 | name="ccpi-viewer", 60 | version=version, 61 | packages=[ 62 | "ccpi", "ccpi.viewer", "ccpi.viewer.utils", "ccpi.web_viewer", "ccpi.viewer.widgets", "ccpi.viewer.cli", 63 | "ccpi.viewer.ui" 64 | ], 65 | install_requires=requires, 66 | zip_safe=False, 67 | # metadata for upload to PyPI 68 | author="Edoardo Pasca", 69 | author_email="edoardo.pasca@stfc.ac.uk", 70 | description="CCPi CILViewer", 71 | license="Apache v2.0", 72 | keywords="3D data viewer", 73 | url="http://www.ccpi.ac.uk", # project home page, if any 74 | entry_points={ 75 | "console_scripts": [ 76 | "resample = ccpi.viewer.cli.resample:main", "web_cilviewer = ccpi.web_viewer.web_app:main", 77 | "cilviewer = ccpi.viewer.standalone_viewer:main" 78 | ] 79 | }, 80 | ) 81 | -------------------------------------------------------------------------------- /Wrappers/Python/test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TomographicImaging/CILViewer/65c59499e25fc3a6131bae78201e83c097e0ce2b/Wrappers/Python/test/__init__.py -------------------------------------------------------------------------------- /Wrappers/Python/test/test_CILViewer3D.py: -------------------------------------------------------------------------------- 1 | # Copyright 2023 STFC, United Kingdom Research and Innovation 2 | # 3 | # Author 2023 Laura Murgatroyd 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | # 17 | import os 18 | import unittest 19 | from unittest import mock 20 | 21 | from ccpi.viewer.CILViewer import CILViewer 22 | 23 | # skip the tests on GitHub actions 24 | if os.environ.get('CONDA_BUILD', '0') == '1': 25 | skip_test = True 26 | else: 27 | skip_test = False 28 | 29 | print("skip_test is set to ", skip_test) 30 | 31 | 32 | @unittest.skipIf(skip_test, "Skipping tests on GitHub Actions") 33 | class CILViewer3DTest(unittest.TestCase): 34 | 35 | def setUp(self): 36 | self.cil_viewer = CILViewer() 37 | 38 | def test_getGradientOpacityPercentiles_returns_correct_percentiles_when_image_values_start_at_zero(self): 39 | self.cil_viewer.gradient_opacity_limits = [40, 50] 40 | self.cil_viewer.getImageMapWholeRange = mock.MagicMock(return_value=[0, 50]) 41 | expected_percentages = (80.0, 100.0) 42 | actual_percentages = self.cil_viewer.getGradientOpacityPercentiles() 43 | self.assertEqual(expected_percentages, actual_percentages) 44 | 45 | def test_getGradientOpacityPercentiles_returns_correct_percentiles_when_image_values_start_at_non_zero(self): 46 | self.cil_viewer.gradient_opacity_limits = [40, 50] 47 | self.cil_viewer.getImageMapWholeRange = mock.MagicMock(return_value=[10, 50]) 48 | expected_percentages = (75.0, 100.0) 49 | actual_percentages = self.cil_viewer.getGradientOpacityPercentiles() 50 | self.assertEqual(expected_percentages, actual_percentages) 51 | 52 | def test_getScalarOpacityPercentiles_returns_correct_percentiles_when_image_values_start_at_zero(self): 53 | self.cil_viewer.scalar_opacity_limits = [40, 50] 54 | self.cil_viewer.getImageMapWholeRange = mock.MagicMock(return_value=[0, 50]) 55 | expected_percentages = (80.0, 100.0) 56 | actual_percentages = self.cil_viewer.getScalarOpacityPercentiles() 57 | self.assertEqual(expected_percentages, actual_percentages) 58 | 59 | def test_getScalarOpacityPercentiles_returns_correct_percentiles_when_image_values_start_at_non_zero(self): 60 | self.cil_viewer.scalar_opacity_limits = [40, 50] 61 | self.cil_viewer.getImageMapWholeRange = mock.MagicMock(return_value=[10, 50]) 62 | expected_percentages = (75.0, 100.0) 63 | actual_percentages = self.cil_viewer.getScalarOpacityPercentiles() 64 | self.assertEqual(expected_percentages, actual_percentages) 65 | 66 | def test_getVolumeColorPercentiles_returns_correct_percentiles_when_image_values_start_at_zero(self): 67 | self.cil_viewer.volume_colormap_limits = [40, 50] 68 | self.cil_viewer.getImageMapWholeRange = mock.MagicMock(return_value=[0, 50]) 69 | expected_percentages = (80.0, 100.0) 70 | actual_percentages = self.cil_viewer.getVolumeColorPercentiles() 71 | self.assertEqual(expected_percentages, actual_percentages) 72 | 73 | def test_getVolumeColorPercentiles_returns_correct_percentiles_when_image_values_start_at_non_zero(self): 74 | self.cil_viewer.volume_colormap_limits = [40, 50] 75 | self.cil_viewer.getImageMapWholeRange = mock.MagicMock(return_value=[10, 50]) 76 | expected_percentages = (75.0, 100.0) 77 | actual_percentages = self.cil_viewer.getVolumeColorPercentiles() 78 | self.assertEqual(expected_percentages, actual_percentages) 79 | 80 | 81 | if __name__ == '__main__': 82 | unittest.main() 83 | -------------------------------------------------------------------------------- /Wrappers/Python/test/test_CILViewerBase.py: -------------------------------------------------------------------------------- 1 | # Copyright 2023 STFC, United Kingdom Research and Innovation 2 | # 3 | # Author 2023 Laura Murgatroyd 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | # 17 | import unittest 18 | from unittest import mock 19 | import os 20 | 21 | from ccpi.viewer.CILViewer import CILViewerBase 22 | 23 | # skip the tests on GitHub actions 24 | if os.environ.get('CONDA_BUILD', '0') == '1': 25 | skip_test = True 26 | else: 27 | skip_test = False 28 | 29 | print("skip_test is set to ", skip_test) 30 | 31 | 32 | @unittest.skipIf(skip_test, "Skipping tests on GitHub Actions") 33 | class CILViewerBaseTest(unittest.TestCase): 34 | 35 | def setUp(self): 36 | '''Creates an instance of the CIL viewer base class.''' 37 | self.CILViewerBase_instance = CILViewerBase() 38 | 39 | def test_setAxisLabels(self): 40 | '''Edits the labels in the axes widget and checks they have been set correctly. 41 | The test is performed with overwrite flag set to default, True, or False.''' 42 | labels = ['a', 'b', 'c'] 43 | self.CILViewerBase_instance.setAxisLabels(labels) 44 | new_labels = self.CILViewerBase_instance.getCurrentAxisLabelsText() 45 | self.assertEqual(new_labels, labels) 46 | self.assertEqual(self.CILViewerBase_instance.axisLabelsText, labels) 47 | 48 | labels_2 = ['c', 'd', 'e'] 49 | self.CILViewerBase_instance.setAxisLabels(labels_2, False) 50 | new_labels = self.CILViewerBase_instance.getCurrentAxisLabelsText() 51 | self.assertEqual(new_labels, labels_2) 52 | self.assertEqual(self.CILViewerBase_instance.axisLabelsText, labels) 53 | 54 | 55 | @unittest.skipIf(skip_test, "Skipping tests on GitHub Actions") 56 | class CILViewer3DTest(unittest.TestCase): 57 | 58 | def setUp(self): 59 | self.cil_viewer = CILViewerBase() 60 | 61 | def test_getSliceColorPercentiles_returns_correct_percentiles_when_slice_values_start_at_zero(self): 62 | self.cil_viewer.getSliceMapRange = mock.MagicMock(return_value=[40, 50]) 63 | self.cil_viewer.getSliceMapWholeRange = mock.MagicMock(return_value=[0, 50]) 64 | expected_percentages = (80.0, 100.0) 65 | actual_percentages = self.cil_viewer.getSliceColorPercentiles() 66 | self.assertEqual(expected_percentages, actual_percentages) 67 | 68 | def test_getSliceColorPercentiles_returns_correct_percentiles_when_slice_values_start_at_non_zero(self): 69 | self.cil_viewer.getSliceMapRange = mock.MagicMock(return_value=[40, 50]) 70 | self.cil_viewer.getSliceMapWholeRange = mock.MagicMock(return_value=[10, 50]) 71 | expected_percentages = (75.0, 100.0) 72 | actual_percentages = self.cil_viewer.getSliceColorPercentiles() 73 | self.assertEqual(expected_percentages, actual_percentages) 74 | 75 | def test_multiple_widgets_cant_be_saved_with_same_name(self): 76 | widget1 = mock.MagicMock() 77 | widget2 = mock.MagicMock() 78 | widget_name = "widget1" 79 | self.cil_viewer.addWidgetReference(widget1, widget_name) 80 | with self.assertRaises(ValueError): 81 | self.cil_viewer.addWidgetReference(widget2, widget_name) 82 | 83 | 84 | if __name__ == '__main__': 85 | unittest.main() 86 | -------------------------------------------------------------------------------- /Wrappers/Python/test/test_camera_data.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2022 STFC, United Kingdom Research and Innovation 3 | # 4 | # Author 2022 Samuel Jones 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # http://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | import unittest 19 | 20 | import vtk 21 | 22 | from ccpi.viewer.utils import CameraData 23 | 24 | 25 | class CameraDataTest(unittest.TestCase): 26 | 27 | def setUp(self): 28 | self.camera = vtk.vtkCamera() 29 | self.cam_pos = (1., 1., 1.) 30 | self.focal_pos = (2., 2., 2.) 31 | self.view_up = (0.57735026, 0.57735026, 0.57735026) 32 | self.camera.SetPosition(*self.cam_pos) 33 | self.camera.SetFocalPoint(*self.focal_pos) 34 | self.camera.SetViewUp(*self.view_up) 35 | 36 | self.data = CameraData(self.camera) 37 | 38 | def test_init_sets_all_values(self): 39 | self.assertEqual(self.data.position, self.cam_pos) 40 | self.assertEqual(self.data.focalPoint, self.focal_pos) 41 | self.assertAlmostEqual(self.data.viewUp[0], self.view_up[0]) 42 | self.assertAlmostEqual(self.data.viewUp[1], self.view_up[1]) 43 | self.assertAlmostEqual(self.data.viewUp[2], self.view_up[2]) 44 | 45 | def test_copy_data_to_camera_does_so(self): 46 | camera_to_copy_to = vtk.vtkCamera() 47 | 48 | self.assertNotEqual(self.cam_pos, camera_to_copy_to.GetPosition()) 49 | self.assertNotEqual(self.focal_pos, camera_to_copy_to.GetFocalPoint()) 50 | self.assertNotEqual(self.view_up, camera_to_copy_to.GetViewUp()) 51 | 52 | CameraData.CopyDataToCamera(self.data, camera_to_copy_to) 53 | 54 | self.assertEqual(self.cam_pos, camera_to_copy_to.GetPosition()) 55 | self.assertEqual(self.focal_pos, camera_to_copy_to.GetFocalPoint()) 56 | self.assertAlmostEqual(self.view_up[0], camera_to_copy_to.GetViewUp()[0]) 57 | self.assertAlmostEqual(self.view_up[1], camera_to_copy_to.GetViewUp()[1]) 58 | self.assertAlmostEqual(self.view_up[2], camera_to_copy_to.GetViewUp()[2]) 59 | -------------------------------------------------------------------------------- /Wrappers/Python/test/test_cli_resample.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest 3 | 4 | import numpy as np 5 | import os 6 | import unittest 7 | 8 | import h5py 9 | import numpy as np 10 | import vtk 11 | from ccpi.viewer.utils.conversion import Converter 12 | from ccpi.viewer.utils.io import cilviewerHDF5Reader 13 | import yaml 14 | 15 | from os import system 16 | 17 | 18 | def calculate_target_downsample_shape(max_size, total_size, shape, acq=False): 19 | if not acq: 20 | xy_axes_magnification = np.power(max_size / total_size, 1 / 3) 21 | slice_per_chunk = int(1 / xy_axes_magnification) 22 | else: 23 | slice_per_chunk = 1 24 | xy_axes_magnification = np.power(max_size / total_size, 1 / 2) 25 | num_chunks = 1 + len([i for i in range(slice_per_chunk, shape[2], slice_per_chunk)]) 26 | 27 | target_image_shape = (int(xy_axes_magnification * shape[0]), int(xy_axes_magnification * shape[1]), num_chunks) 28 | return target_image_shape 29 | 30 | 31 | class TestCLIResample(unittest.TestCase): 32 | 33 | def setUp(self): 34 | # Generate random 3D array: 35 | bits = 16 36 | self.bytes_per_element = int(bits / 8) 37 | self.input_3D_array = np.random.randint(10, size=(5, 10, 6), dtype=eval(f"np.uint{bits}")) 38 | 39 | # write to HDF5: ----------- 40 | self.hdf5_filename_3D = 'test_3D_data.h5' 41 | self.hdf5_yaml_filename = 'test_hdf5.yaml' 42 | with h5py.File(self.hdf5_filename_3D, 'w') as f: 43 | f.create_dataset('/entry1/tomo_entry/data/data', data=self.input_3D_array) 44 | 45 | self.hdf5_dict = { 46 | 'input': { 47 | 'file_name': self.hdf5_filename_3D, 48 | 'dataset_name': '/entry1/tomo_entry/data/data' 49 | }, 50 | 'resample': { 51 | 'target_size': 100e-6, 52 | 'resample_z': False 53 | }, 54 | 'output': { 55 | 'file_name': 'test_hdf5_out.hdf5', 56 | 'format': 'hdf5' 57 | } 58 | } 59 | with open(self.hdf5_yaml_filename, 'w') as file: 60 | yaml.dump(self.hdf5_dict, file) 61 | 62 | # write to raw: ------------- 63 | bytes_3D_array = bytes(self.input_3D_array) 64 | self.raw_filename_3D = 'test_3D_data.raw' 65 | self.raw_yaml_filename = 'test_raw.yaml' 66 | with open(self.raw_filename_3D, 'wb') as f: 67 | f.write(bytes_3D_array) 68 | self.raw_dict = { 69 | 'input': { 70 | 'file_name': self.raw_filename_3D, 71 | 'shape': str(np.shape(self.input_3D_array)), 72 | 'is_fortran': False, 73 | 'is_big_endian': False, 74 | 'typecode': str(self.input_3D_array.dtype) 75 | }, 76 | 'resample': { 77 | 'target_size': 100e-6, 78 | 'resample_z': False 79 | }, 80 | 'output': { 81 | 'file_name': 'test_raw_out.hdf5', 82 | 'format': 'hdf5' 83 | } 84 | } 85 | with open(self.raw_yaml_filename, 'w') as file: 86 | yaml.dump(self.raw_dict, file) 87 | 88 | def _test_resampling_acq_data(self, reader, target_size): 89 | og_shape = np.shape(self.input_3D_array) 90 | og_shape = (og_shape[2], og_shape[1], og_shape[0]) 91 | og_size = og_shape[0] * og_shape[1] * og_shape[2] * self.bytes_per_element 92 | reader.Update() 93 | image = reader.GetOutputDataObject(0) 94 | extent = image.GetExtent() 95 | 96 | shape = calculate_target_downsample_shape(target_size, og_size, og_shape, acq=True) 97 | expected_size = shape[0] * shape[1] * shape[2] 98 | resulting_shape = (extent[1] + 1, (extent[3] + 1), (extent[5] + 1)) 99 | resulting_size = resulting_shape[0] * \ 100 | resulting_shape[1]*resulting_shape[2] 101 | # angle (z direction) is first index in numpy array, and in cil 102 | # but it is the last in vtk. 103 | resulting_z_shape = extent[5] + 1 104 | og_z_shape = np.shape(self.input_3D_array)[0] 105 | 106 | self.assertEqual(resulting_size, expected_size) 107 | self.assertEqual(resulting_z_shape, og_z_shape) 108 | 109 | def test_resample_with_yaml(self): 110 | dicts = [self.hdf5_dict, self.raw_dict] 111 | filenames = [self.hdf5_yaml_filename, self.raw_yaml_filename] 112 | subtest_labels = ['HDF5', 'raw'] 113 | for i, dict in enumerate(dicts): 114 | with self.subTest(format=subtest_labels[i]): 115 | yaml_file = filenames[i] 116 | 117 | # Tests image with correct target size is generated by resample reader: 118 | system('resample -f {}'.format(yaml_file)) 119 | 120 | reader = cilviewerHDF5Reader() 121 | reader.SetFileName(dict['output']['file_name']) 122 | target_size = int(dict['resample']['target_size'] * 1e6) 123 | self._test_resampling_acq_data(reader, target_size) 124 | 125 | def test_resample_command_line_hdf5(self): 126 | dict = self.hdf5_dict 127 | 128 | # Tests image with correct target size is generated by resample reader: 129 | input_file = dict['input']['file_name'] 130 | target_size = dict['resample']['target_size'] 131 | resample_z = dict['resample']['resample_z'] 132 | out = dict['output']['file_name'] 133 | out_format = dict['output']['format'] 134 | 135 | # specific to hdf5: 136 | dset_name = dict['input']['dataset_name'] 137 | 138 | command = f'resample -i {input_file} --dataset_name {dset_name} -o {out} -target_size {target_size} --resample_z {resample_z} --out_format {out_format}' 139 | 140 | if system(command) != 0: 141 | raise Exception("Error running test_resample_command_line_hdf5") 142 | 143 | reader = cilviewerHDF5Reader() 144 | reader.SetFileName(dict['output']['file_name']) 145 | 146 | target_size = int(target_size * 1e6) 147 | self._test_resampling_acq_data(reader, target_size) 148 | 149 | def test_resample_command_line_raw(self): 150 | dict = self.raw_dict 151 | 152 | # Tests image with correct target size is generated by resample reader: 153 | input_file = dict['input']['file_name'] 154 | target_size = dict['resample']['target_size'] 155 | resample_z = dict['resample']['resample_z'] 156 | out = dict['output']['file_name'] 157 | out_format = dict['output']['format'] 158 | 159 | # specific to raw: 160 | shape = list(eval(dict['input']['shape'])) 161 | shape = f"{shape[0]},{shape[1]},{shape[2]}" 162 | is_fortran = dict['input']['is_fortran'] 163 | # is_fortran = False 164 | is_big_endian = dict['input']['is_big_endian'] 165 | typecode = dict['input']['typecode'] 166 | 167 | command = f'resample -i {input_file} --shape {shape} --is_fortran {is_fortran} --is_big_endian {is_big_endian} --typecode {typecode} -o {out} -target_size {target_size} --resample_z {resample_z} --out_format {out_format}' 168 | 169 | if system(command) != 0: 170 | raise Exception("Error running test_resample_command_line_raw") 171 | 172 | reader = cilviewerHDF5Reader() 173 | reader.SetFileName(dict['output']['file_name']) 174 | 175 | target_size = int(target_size * 1e6) 176 | self._test_resampling_acq_data(reader, target_size) 177 | 178 | def tearDown(self): 179 | files = [self.hdf5_filename_3D, self.hdf5_yaml_filename, self.raw_filename_3D, self.raw_yaml_filename] 180 | for f in files: 181 | os.remove(f) 182 | 183 | 184 | if __name__ == '__main__': 185 | unittest.main() 186 | -------------------------------------------------------------------------------- /Wrappers/Python/test/test_conversion.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest 3 | 4 | import numpy as np 5 | import vtk 6 | from ccpi.viewer.utils.conversion import (Converter, cilRawResampleReader, cilMetaImageResampleReader, 7 | cilNumpyResampleReader, cilNumpyMETAImageWriter) 8 | 9 | import numpy as np 10 | ''' 11 | This will test parts of the utils/conversion.py file other than 12 | the Resample and Cropped readers. (See test_cropped_readers.py 13 | and test_resample_readers.py for tests of these) 14 | 15 | ''' 16 | 17 | 18 | class TestConversion(unittest.TestCase): 19 | 20 | def setUp(self): 21 | # Generate random 3D array and write to HDF5: 22 | np.random.seed(1) 23 | self.input_3D_array = np.random.randint(10, size=(5, 10, 6), dtype=np.uint8) 24 | bytes_3D_array = bytes(self.input_3D_array) 25 | self.raw_filename_3D = 'test_3D_data.raw' 26 | with open(self.raw_filename_3D, 'wb') as f: 27 | f.write(bytes_3D_array) 28 | 29 | def test_WriteMETAImageHeader(self): 30 | '''writes a mhd file to go with a raw 31 | datafile, using cilNumpyMETAImageWriter.WriteMETAImageHeader and then tests if this can 32 | be read successfully with vtk.vtkMetaImageReader 33 | by comparing to array read with cilRawResampleReader 34 | directly from RawResampleReader and the original contents''' 35 | 36 | # read raw file's info: 37 | data_filename = self.raw_filename_3D 38 | header_filename = 'raw_header.mhd' 39 | typecode = 'uint8' 40 | big_endian = False 41 | header_length = 0 42 | shape = np.shape(self.input_3D_array) 43 | shape_to_write = shape[::-1] # because it is not a fortran order array we have to swap 44 | cilNumpyMETAImageWriter.WriteMETAImageHeader(data_filename, 45 | header_filename, 46 | typecode, 47 | big_endian, 48 | header_length, 49 | shape_to_write, 50 | spacing=(1., 1., 1.), 51 | origin=(0., 0., 0.)) 52 | 53 | reader = vtk.vtkMetaImageReader() 54 | reader.SetFileName(header_filename) 55 | reader.Update() 56 | read_mhd_raw = Converter.vtk2numpy(reader.GetOutput()) 57 | 58 | reader = cilRawResampleReader() 59 | target_size = int(1e12) 60 | reader.SetTargetSize(target_size) 61 | reader.SetBigEndian(False) 62 | reader.SetIsFortran(False) 63 | reader.SetFileName(self.raw_filename_3D) 64 | raw_type_code = str(self.input_3D_array.dtype) 65 | reader.SetTypeCodeName(raw_type_code) 66 | reader.SetStoredArrayShape(shape) 67 | reader.Update() 68 | 69 | image = reader.GetOutput() 70 | raw_array = Converter.vtk2numpy(image) 71 | 72 | np.testing.assert_array_equal(read_mhd_raw, raw_array) 73 | np.testing.assert_array_equal(read_mhd_raw, self.input_3D_array) 74 | 75 | def tearDown(self): 76 | files = [self.raw_filename_3D] 77 | for f in files: 78 | os.remove(f) 79 | 80 | 81 | if __name__ == '__main__': 82 | unittest.main() 83 | -------------------------------------------------------------------------------- /Wrappers/Python/test/test_cropped_readers.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest 3 | 4 | import numpy as np 5 | import vtk 6 | from ccpi.viewer.utils.conversion import (Converter, cilRawCroppedReader, cilMetaImageCroppedReader, 7 | cilNumpyCroppedReader, cilTIFFCroppedReader) 8 | 9 | 10 | class TestCroppedReaders(unittest.TestCase): 11 | 12 | def setUp(self): 13 | # Generate random 3D array and write to HDF5: 14 | np.random.seed(1) 15 | shape = (5, 4, 6) # was 10 times larger 16 | bits = 8 17 | self.input_3D_array = np.random.randint(10, size=shape, dtype=eval(f"np.uint{bits}")) 18 | self.input_3D_array = np.reshape(np.arange(self.input_3D_array.size), 19 | newshape=shape).astype(dtype=eval(f"np.uint{bits}")) 20 | self.raw_type_code = str(self.input_3D_array.dtype) 21 | bytes_3D_array = bytes(self.input_3D_array) 22 | self.raw_filename_3D = 'test_3D_data.raw' 23 | with open(self.raw_filename_3D, 'wb') as f: 24 | f.write(bytes_3D_array) 25 | 26 | self.numpy_filename_3D = 'test_3D_data.npy' 27 | np.save(self.numpy_filename_3D, self.input_3D_array) 28 | 29 | self.meta_filename_3D = 'test_3D_data.mha' 30 | vtk_image = Converter.numpy2vtkImage(self.input_3D_array) 31 | writer = vtk.vtkMetaImageWriter() 32 | writer.SetFileName(self.meta_filename_3D) 33 | writer.SetInputData(vtk_image) 34 | writer.SetCompression(False) 35 | writer.Write() 36 | # Write TIFFs 37 | fnames = [] 38 | arr = self.input_3D_array 39 | from PIL import Image 40 | for i in range(arr.shape[0]): 41 | fname = 'tiff_test_file_{:03d}.tiff'.format(i) 42 | fnames.append(os.path.abspath(fname)) 43 | # Using vtk the Y axis gets reversed 44 | # vtk_image = Converter.numpy2vtkImage(np.expand_dims(arr[i,:,:], axis=0)) 45 | # twriter.SetFileName(fnames[-1]) 46 | # twriter.SetInputData(vtk_image) 47 | # twriter.Write() 48 | im = Image.fromarray(arr[i]) 49 | im.save(fnames[-1]) 50 | 51 | self.tiff_fnames = fnames 52 | 53 | def check_extent(self, reader, target_z_extent): 54 | reader.Update() 55 | image = reader.GetOutput() 56 | extent = list(image.GetExtent()) 57 | og_shape = np.shape(self.input_3D_array) 58 | og_extent = [0, og_shape[2] - 1, 0, og_shape[1] - 1, 0, og_shape[0] - 1] 59 | expected_extent = og_extent 60 | expected_extent[4] = target_z_extent[0] 61 | expected_extent[5] = target_z_extent[1] 62 | self.assertEqual(extent, expected_extent) 63 | 64 | def check_values(self, target_z_extent, read_cropped_image, expected_array=None): 65 | if expected_array is None: 66 | expected_array = self.input_3D_array 67 | read_cropped_array = Converter.vtk2numpy(read_cropped_image) 68 | cropped_array = expected_array[target_z_extent[0]:target_z_extent[1] + 1, :, :] 69 | np.testing.assert_array_equal(cropped_array, read_cropped_array) 70 | 71 | def test_raw_cropped_reader(self): 72 | target_z_extent = [1, 3] 73 | reader = cilRawCroppedReader() 74 | og_shape = np.shape(self.input_3D_array) 75 | reader.SetFileName(self.raw_filename_3D) 76 | reader.SetTargetZExtent(tuple(target_z_extent)) 77 | reader.SetBigEndian(False) 78 | reader.SetIsFortran(False) 79 | raw_type_code = str(self.input_3D_array.dtype) 80 | reader.SetTypeCodeName(raw_type_code) 81 | reader.SetStoredArrayShape(og_shape) 82 | self.check_extent(reader, target_z_extent) 83 | self.check_values(target_z_extent, reader.GetOutput()) 84 | # Check raw type code was set correctly: 85 | self.assertEqual(raw_type_code, reader.GetTypeCodeName()) 86 | 87 | def test_meta_and_numpy_cropped_readers(self): 88 | readers = [cilNumpyCroppedReader(), cilMetaImageCroppedReader()] 89 | filenames = [self.numpy_filename_3D, self.meta_filename_3D] 90 | subtest_labels = ['cilNumpyCroppedReader', 'cilMetaImageCroppedReader'] 91 | for i, reader in enumerate(readers): 92 | with self.subTest(reader=subtest_labels[i]): 93 | filename = filenames[i] 94 | target_z_extent = [1, 3] 95 | reader.SetFileName(filename) 96 | reader.SetTargetZExtent(tuple(target_z_extent)) 97 | self.check_extent(reader, target_z_extent) 98 | self.check_values(target_z_extent, reader.GetOutput()) 99 | 100 | def _setup_tiff_cropped_reader(self, target_z_extent): 101 | reader = cilTIFFCroppedReader() 102 | reader.SetFileName(self.tiff_fnames) 103 | reader.SetTargetZExtent(target_z_extent) 104 | return reader 105 | 106 | def test_tiff_cropped_reader(self): 107 | target_z_extent = [1, 3] 108 | reader = self._setup_tiff_cropped_reader(tuple(target_z_extent)) 109 | self.check_extent(reader, target_z_extent) 110 | # Check raw type code was set correctly: 111 | self.assertEqual(self.raw_type_code, reader.GetTypeCodeName()) 112 | self.check_values(target_z_extent, reader.GetOutput()) 113 | 114 | def test_tiff_cropped_reader_when_orientation_set(self): 115 | target_z_extent = [1, 3] 116 | reader = self._setup_tiff_cropped_reader(tuple(target_z_extent)) 117 | reader.SetOrientationType(4) # this flips the y axis 118 | expected_array = np.flip(np.copy(self.input_3D_array), axis=1) 119 | self.check_extent(reader, target_z_extent) 120 | # Check raw type code was set correctly: 121 | self.assertEqual(self.raw_type_code, reader.GetTypeCodeName()) 122 | self.check_values(target_z_extent, reader.GetOutput(), expected_array) 123 | 124 | def tearDown(self): 125 | files = [self.raw_filename_3D, self.numpy_filename_3D, self.meta_filename_3D] + self.tiff_fnames 126 | for f in files: 127 | os.remove(f) 128 | 129 | 130 | if __name__ == '__main__': 131 | unittest.main() 132 | -------------------------------------------------------------------------------- /Wrappers/Python/test/test_example_data.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from ccpi.viewer.utils import example_data 3 | 4 | 5 | class TestExampleData(unittest.TestCase): 6 | 7 | def test_head_data(self): 8 | data = example_data.HEAD.get() 9 | expected_dimensions = [64, 64, 93] 10 | read_dimensions = data.GetDimensions() 11 | 12 | for i in range(0, len(expected_dimensions)): 13 | self.assertEqual(expected_dimensions[i], read_dimensions[i]) 14 | 15 | 16 | if __name__ == '__main__': 17 | unittest.main() 18 | -------------------------------------------------------------------------------- /Wrappers/Python/test/test_hdf5.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest 3 | 4 | import h5py 5 | import numpy as np 6 | import vtk 7 | from ccpi.viewer.utils.conversion import Converter, calculate_target_downsample_shape, cilHDF5CroppedReader, cilHDF5ResampleReader 8 | from ccpi.viewer.utils.hdf5_io import (HDF5Reader, HDF5SubsetReader, write_image_data_to_hdf5) 9 | 10 | 11 | class TestHDF5IO(unittest.TestCase): 12 | 13 | def setUp(self): 14 | # Generate random 3D array and write to HDF5: 15 | np.random.seed(1) 16 | self.input_3D_array = np.random.random(size=(5, 10, 6)) 17 | self.hdf5_filename_3D = 'test_3D_data.h5' 18 | with h5py.File(self.hdf5_filename_3D, 'w') as f: 19 | f.create_dataset('ImageData', data=self.input_3D_array) 20 | 21 | # Generate random 4D array and write to HDF5: 22 | self.input_4D_array = np.random.random(size=(10, 7, 10, 6)) 23 | self.hdf5_filename_4D = 'test_4D_data.h5' 24 | with h5py.File(self.hdf5_filename_4D, 'w') as f: 25 | f.create_dataset('ImageData', data=self.input_4D_array) 26 | 27 | def test_read_hdf5(self): 28 | # Write a numpy array to a HDF5 and then test using 29 | # HDF5Reader to read it back: 30 | reader = HDF5Reader() 31 | reader.SetFileName(self.hdf5_filename_3D) 32 | reader.SetDatasetName("ImageData") 33 | reader.Update() 34 | array_image_data = reader.GetOutputDataObject(0) 35 | read_array = Converter.vtk2numpy(array_image_data) 36 | np.testing.assert_array_equal(self.input_3D_array, read_array) 37 | 38 | def test_read_hdf5_channel(self): 39 | # Test reading a specific channel in a 4D dataset 40 | channel_index = 5 41 | reader = HDF5Reader() 42 | reader.SetFileName(self.hdf5_filename_4D) 43 | reader.SetDatasetName("ImageData") 44 | reader.Set4DSliceIndex(channel_index) 45 | reader.Set4DIndex(0) 46 | reader.Update() 47 | array_image_data = reader.GetOutputDataObject(0) 48 | read_array = Converter.vtk2numpy(array_image_data) 49 | np.testing.assert_array_equal(self.input_4D_array[channel_index], read_array) 50 | 51 | def test_hdf5_subset_reader(self): 52 | # With the subset reader: ----------------------------- 53 | # Test cropping the extent of a dataset 54 | cropped_array = self.input_3D_array[1:3, 3:6, 0:3] 55 | reader = HDF5Reader() 56 | reader.SetFileName(self.hdf5_filename_3D) 57 | reader.SetDatasetName("ImageData") 58 | cropped_reader = HDF5SubsetReader() 59 | cropped_reader.SetInputConnection(reader.GetOutputPort()) 60 | # NOTE: the extent is in vtk so is in fortran order, whereas 61 | # above we had the np array in C-order so x and y cropping swapped 62 | cropped_reader.SetUpdateExtent((0, 2, 3, 5, 1, 2)) 63 | cropped_reader.Update() 64 | array_image_data = cropped_reader.GetOutputDataObject(0) 65 | read_cropped_array = Converter.vtk2numpy(array_image_data) 66 | np.testing.assert_array_equal(cropped_array, read_cropped_array) 67 | 68 | def test_read_cropped_hdf5_reader(self): 69 | # # With the Cropped reader: ----------------------------- 70 | cropped_array = self.input_3D_array[1:3, 3:6, 0:3] 71 | reader = cilHDF5CroppedReader() 72 | reader.SetFileName(self.hdf5_filename_3D) 73 | reader.SetDatasetName("ImageData") 74 | reader.SetTargetExtent((0, 2, 3, 5, 1, 2)) 75 | reader.Update() 76 | array_image_data = reader.GetOutput() 77 | read_cropped_array = Converter.vtk2numpy(array_image_data) 78 | np.testing.assert_array_equal(cropped_array, read_cropped_array) 79 | 80 | def test_read_cropped_hdf5_channel(self): 81 | # Test cropping the extent of a dataset 82 | channel_index = 5 83 | cropped_array = self.input_4D_array[1:2, channel_index, 3:6, 0:3] 84 | hdf5_filename = 'test_numpy_data.h5' 85 | with h5py.File(hdf5_filename, 'w') as f: 86 | f.create_dataset('ImageData', data=self.input_4D_array) 87 | reader = HDF5Reader() 88 | reader.SetFileName(self.hdf5_filename_4D) 89 | reader.SetDatasetName("ImageData") 90 | reader.Set4DSliceIndex(channel_index) 91 | reader.Set4DIndex(1) 92 | cropped_reader = HDF5SubsetReader() 93 | cropped_reader.SetInputConnection(reader.GetOutputPort()) 94 | # NOTE: the extent is in vtk so is in fortran order, whereas 95 | # above we had the np array in C-order so x and y cropping swapped 96 | cropped_reader.SetUpdateExtent((0, 2, 3, 5, 1, 1)) 97 | cropped_reader.Update() 98 | array_image_data = cropped_reader.GetOutputDataObject(0) 99 | read_cropped_array = Converter.vtk2numpy(array_image_data) 100 | np.testing.assert_array_equal(cropped_array, read_cropped_array) 101 | 102 | def test_write_hdf5(self): 103 | # Provides an example image with extent (-10, 10, -10, 10, -10, 10): 104 | image_source = vtk.vtkRTAnalyticSource() 105 | image_source.Update() 106 | image_data = image_source.GetOutput() 107 | numpy_data = Converter.vtk2numpy(image_data) 108 | 109 | self.hdf5_filename_RT = "test_image_data.hdf5" 110 | 111 | write_image_data_to_hdf5(self.hdf5_filename_RT, image_data, dataset_name='RTData') 112 | 113 | # Test reading hdf5: 114 | reader = HDF5Reader() 115 | reader.SetFileName(self.hdf5_filename_RT) 116 | reader.SetDatasetName('RTData') 117 | reader.Update() 118 | 119 | read_image_data = reader.GetOutputDataObject(0) 120 | read_numpy_data = Converter.vtk2numpy(read_image_data) 121 | np.testing.assert_array_equal(numpy_data, read_numpy_data) 122 | # currently fails because we don't save extent to hdf5: 123 | # self.assertEqual(image_data.GetExtent(), read_image_data.GetExtent()) 124 | os.remove(self.hdf5_filename_RT) 125 | 126 | def test_hdf5_resample_reader(self): 127 | # Tests image with correct target size is generated by resample reader: 128 | # Not a great test, but at least checks the resample reader runs 129 | # without crashing 130 | # TODO: improve this test 131 | readerhdf5 = cilHDF5ResampleReader() 132 | readerhdf5.SetFileName(self.hdf5_filename_3D) 133 | readerhdf5.SetDatasetName("ImageData") 134 | target_size = 100 135 | readerhdf5.SetTargetSize(target_size) 136 | readerhdf5.Update() 137 | image = readerhdf5.GetOutput() 138 | extent = image.GetExtent() 139 | resulting_shape = (extent[1] + 1, (extent[3] + 1), (extent[5] + 1)) 140 | og_shape = np.shape(self.input_3D_array) 141 | og_shape = (og_shape[2], og_shape[1], og_shape[0]) 142 | og_size = og_shape[0] * og_shape[1] * og_shape[2] * readerhdf5.GetBytesPerElement() 143 | expected_shape = calculate_target_downsample_shape(target_size, og_size, og_shape) 144 | self.assertEqual(resulting_shape, expected_shape) 145 | 146 | # Now test if we get the full image extent if our 147 | # target size is larger than the size of the image: 148 | target_size = og_size * 2 149 | readerhdf5.SetTargetSize(target_size) 150 | readerhdf5.Update() 151 | image = readerhdf5.GetOutput() 152 | extent = image.GetExtent() 153 | expected_shape = og_shape 154 | resulting_shape = (extent[1] + 1, (extent[3] + 1), (extent[5] + 1)) 155 | self.assertEqual(resulting_shape, expected_shape) 156 | resulting_array = Converter.vtk2numpy(image) 157 | np.testing.assert_array_equal(self.input_3D_array, resulting_array) 158 | 159 | # Now test if we get the correct z extent if we set that we 160 | # have acquisition data 161 | readerhdf5 = cilHDF5ResampleReader() 162 | readerhdf5.SetDatasetName("ImageData") 163 | readerhdf5.SetFileName(self.hdf5_filename_3D) 164 | target_size = 100 165 | readerhdf5.SetTargetSize(target_size) 166 | readerhdf5.SetIsAcquisitionData(True) 167 | readerhdf5.Update() 168 | image = readerhdf5.GetOutput() 169 | extent = image.GetExtent() 170 | shape_not_acquisition = calculate_target_downsample_shape(target_size, og_size, og_shape, acq=True) 171 | expected_size = shape_not_acquisition[0] * \ 172 | shape_not_acquisition[1]*shape_not_acquisition[2] 173 | resulting_shape = (extent[1] + 1, (extent[3] + 1), (extent[5] + 1)) 174 | resulting_size = resulting_shape[0] * \ 175 | resulting_shape[1]*resulting_shape[2] 176 | # angle (z direction) is first index in numpy array, and in cil 177 | # but it is the last in vtk. 178 | resulting_z_shape = extent[5] + 1 179 | og_z_shape = np.shape(self.input_3D_array)[0] 180 | self.assertEqual(resulting_size, expected_size) 181 | self.assertEqual(resulting_z_shape, og_z_shape) 182 | 183 | def tearDown(self): 184 | files = [self.hdf5_filename_3D, self.hdf5_filename_4D] 185 | for f in files: 186 | os.remove(f) 187 | 188 | 189 | if __name__ == '__main__': 190 | unittest.main() 191 | -------------------------------------------------------------------------------- /Wrappers/Python/test/test_resample_readers.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest 3 | import warnings 4 | 5 | import numpy as np 6 | import vtk 7 | from ccpi.viewer.utils.conversion import Converter, calculate_target_downsample_shape, \ 8 | cilRawResampleReader, cilTIFFResampleReader, cilNumpyMETAImageWriter, cilMetaImageResampleReader,\ 9 | cilNumpyResampleReader 10 | 11 | 12 | class TestResampleReaders(unittest.TestCase): 13 | 14 | def setUp(self): 15 | # Generate random 3D array and write to HDF5: 16 | np.random.seed(1) 17 | bits = 16 18 | self.bytes_per_element = int(bits / 8) 19 | shape = (5, 10, 6) 20 | self.size_to_resample_to = 100 21 | self.size_greater_than_input_size = 10000 22 | self.input_3D_array = np.random.randint(10, size=shape, dtype=eval(f"np.uint{bits}")) 23 | self.input_3D_array = np.reshape(np.arange(self.input_3D_array.size), 24 | newshape=shape).astype(dtype=eval(f"np.uint{bits}")) 25 | bytes_3D_array = bytes(self.input_3D_array) 26 | self.raw_filename_3D = 'test_3D_data.raw' 27 | with open(self.raw_filename_3D, 'wb') as f: 28 | f.write(bytes_3D_array) 29 | 30 | # write header to go with raw file 31 | self.mhd_filename_3D = 'raw_header.mhd' 32 | typecode = f'uint{bits}' 33 | big_endian = False 34 | header_length = 0 35 | shape = np.shape(self.input_3D_array) 36 | shape_to_write = shape[::-1] # because it is not a fortran order array we have to swap 37 | cilNumpyMETAImageWriter.WriteMETAImageHeader(self.raw_filename_3D, 38 | self.mhd_filename_3D, 39 | typecode, 40 | big_endian, 41 | header_length, 42 | shape_to_write, 43 | spacing=(1., 1., 1.), 44 | origin=(0., 0., 0.)) 45 | 46 | self.numpy_filename_3D = 'test_3D_data.npy' 47 | np.save(self.numpy_filename_3D, self.input_3D_array) 48 | 49 | self.meta_filename_3D = 'test_3D_data.mha' 50 | vtk_image = Converter.numpy2vtkImage(self.input_3D_array) 51 | writer = vtk.vtkMetaImageWriter() 52 | writer.SetFileName(self.meta_filename_3D) 53 | writer.SetInputData(vtk_image) 54 | writer.SetCompression(False) 55 | writer.Write() 56 | 57 | # Create TIFF Files 58 | fnames = [] 59 | arr = self.input_3D_array 60 | from PIL import Image 61 | for i in range(arr.shape[0]): 62 | fname = 'tiff_test_file_{:03d}.tiff'.format(i) 63 | fnames.append(os.path.abspath(fname)) 64 | # Using vtk the Y axis gets reversed 65 | # vtk_image = Converter.numpy2vtkImage(np.expand_dims(arr[i,:,:], axis=0)) 66 | # twriter.SetFileName(fnames[-1]) 67 | # twriter.SetInputData(vtk_image) 68 | # twriter.Write() 69 | im = Image.fromarray(arr[i]) 70 | im.save(fnames[-1]) 71 | 72 | self.tiff_fnames = fnames 73 | 74 | def resample_reader_test1(self, reader, target_size, expected_array=None): 75 | # Tests image with correct target size is generated by resample reader: 76 | # Not a great test, but at least checks the resample reader runs 77 | # without crashing 78 | # TODO: improve this test 79 | 80 | if expected_array is None: 81 | expected_array = self.input_3D_array 82 | 83 | reader.SetTargetSize(target_size) 84 | reader.Modified() 85 | reader.Update() 86 | og_shape = np.shape(expected_array) 87 | raw_type_code = str(expected_array.dtype) 88 | 89 | if target_size < self.input_3D_array.size * self.bytes_per_element: 90 | # Check raw type code was set correctly: 91 | self.assertEqual(raw_type_code, reader.GetTypeCodeName()) 92 | image = reader.GetOutput() 93 | extent = image.GetExtent() 94 | resulting_shape = (extent[1] + 1, (extent[3] + 1), (extent[5] + 1)) 95 | og_shape = (og_shape[2], og_shape[1], og_shape[0]) 96 | og_size = og_shape[0] * og_shape[1] * og_shape[2] * self.bytes_per_element 97 | expected_shape = calculate_target_downsample_shape(target_size, og_size, og_shape) 98 | self.assertEqual(resulting_shape, expected_shape) 99 | # Now test if we get the correct z extent if we set that we 100 | # have acquisition data 101 | reader.SetIsAcquisitionData(True) 102 | # why do we need this? 103 | reader.Modified() 104 | reader.Update() 105 | image = reader.GetOutput() 106 | extent2 = image.GetExtent() 107 | extent = extent2 108 | shape_acquisition = calculate_target_downsample_shape(target_size, og_size, og_shape, acq=True) 109 | expected_size = shape_acquisition[0] * \ 110 | shape_acquisition[1]*shape_acquisition[2] 111 | resulting_shape = (extent[1] + 1, (extent[3] + 1), (extent[5] + 1)) 112 | resulting_size = resulting_shape[0] * \ 113 | resulting_shape[1]*resulting_shape[2] 114 | # angle (z direction) is first index in numpy array, and in cil 115 | # but it is the last in vtk. 116 | resulting_z_shape = extent[5] + 1 117 | og_z_shape = og_shape[2] 118 | self.assertEqual(resulting_size, expected_size) 119 | self.assertEqual(resulting_z_shape, og_z_shape) 120 | else: 121 | reader.Modified() 122 | reader.Update() 123 | image = reader.GetOutput() 124 | resulting_shape = image.GetDimensions() 125 | expected_shape = np.shape(expected_array) 126 | self.assertEqual(resulting_shape, expected_shape[::-1]) 127 | resulting_array = Converter.vtk2numpy(image) 128 | np.testing.assert_array_equal(np.asfortranarray(expected_array), resulting_array) 129 | 130 | def test_raw_resample_reader(self): 131 | og_shape = np.shape(self.input_3D_array) 132 | reader = cilRawResampleReader() 133 | reader.SetFileName(self.raw_filename_3D) 134 | reader.SetBigEndian(False) 135 | reader.SetIsFortran(False) 136 | raw_type_code = str(self.input_3D_array.dtype) 137 | reader.SetTypeCodeName(raw_type_code) 138 | reader.SetStoredArrayShape(og_shape) 139 | self.resample_reader_test1(reader, self.size_to_resample_to) 140 | 141 | def test_raw_resample_reader_when_resampling_not_needed(self): 142 | og_shape = np.shape(self.input_3D_array) 143 | reader = cilRawResampleReader() 144 | reader.SetFileName(self.raw_filename_3D) 145 | reader.SetBigEndian(False) 146 | reader.SetIsFortran(False) 147 | raw_type_code = str(self.input_3D_array.dtype) 148 | reader.SetTypeCodeName(raw_type_code) 149 | reader.SetStoredArrayShape(og_shape) 150 | self.resample_reader_test1(reader, self.size_greater_than_input_size) 151 | 152 | def _setup_tiff_resample_reader(self): 153 | reader = cilTIFFResampleReader() 154 | reader.SetFileName(self.tiff_fnames) 155 | return reader 156 | 157 | def test_tiff_resample_reader(self): 158 | reader = self._setup_tiff_resample_reader() 159 | self.resample_reader_test1(reader, self.size_to_resample_to) 160 | 161 | def test_tiff_resample_reader_with_orientation_type_set(self): 162 | reader = self._setup_tiff_resample_reader() 163 | reader.SetOrientationType(4) # this flips the y axis 164 | expected_array = np.flip(np.copy(self.input_3D_array), axis=1) 165 | self.resample_reader_test1(reader, self.size_to_resample_to, expected_array) 166 | 167 | def test_tiff_resample_reader_when_resampling_not_needed(self): 168 | reader = self._setup_tiff_resample_reader() 169 | self.resample_reader_test1(reader, self.size_greater_than_input_size) 170 | 171 | def test_tiff_resample_reader_with_orientation_type_set_when_resampling_not_needed(self): 172 | reader = self._setup_tiff_resample_reader() 173 | reader.SetOrientationType(4) # this flips the y axis 174 | expected_array = np.flip(np.copy(self.input_3D_array), axis=1) 175 | self.resample_reader_test1(reader, self.size_greater_than_input_size, expected_array) 176 | 177 | def test_meta_resample_reader_mha(self): 178 | reader = cilMetaImageResampleReader() 179 | reader.SetFileName(self.meta_filename_3D) 180 | self.resample_reader_test1(reader, self.size_to_resample_to) 181 | 182 | def test_meta_resample_reader_mha_when_resampling_not_needed(self): 183 | reader = cilMetaImageResampleReader() 184 | reader.SetFileName(self.meta_filename_3D) 185 | self.resample_reader_test1(reader, self.size_greater_than_input_size) 186 | 187 | def test_meta_resample_reader_mhd(self): 188 | reader = cilMetaImageResampleReader() 189 | reader.SetFileName(self.mhd_filename_3D) 190 | self.resample_reader_test1(reader, self.size_to_resample_to) 191 | 192 | def test_meta_resample_reader_mhd_when_resampling_not_needed(self): 193 | reader = cilMetaImageResampleReader() 194 | reader.SetFileName(self.mhd_filename_3D) 195 | self.resample_reader_test1(reader, self.size_greater_than_input_size) 196 | 197 | def test_npy_resample_reader(self): 198 | reader = cilNumpyResampleReader() 199 | reader.SetFileName(self.numpy_filename_3D) 200 | self.resample_reader_test1(reader, self.size_to_resample_to) 201 | self.resample_reader_test1(reader, self.size_greater_than_input_size) 202 | 203 | def test_npy_resample_reader_when_resampling_not_needed(self): 204 | reader = cilNumpyResampleReader() 205 | reader.SetFileName(self.numpy_filename_3D) 206 | self.resample_reader_test1(reader, self.size_to_resample_to) 207 | self.resample_reader_test1(reader, self.size_greater_than_input_size) 208 | 209 | def tearDown(self): 210 | files = [self.raw_filename_3D, self.numpy_filename_3D, self.meta_filename_3D 211 | ] + self.tiff_fnames + [self.mhd_filename_3D] 212 | for f in files: 213 | # print (f'removing {f}') 214 | os.remove(f) 215 | if os.path.exists(f): 216 | warnings.warn(f'file {f} not deleted!') 217 | 218 | 219 | if __name__ == '__main__': 220 | unittest.main() 221 | -------------------------------------------------------------------------------- /Wrappers/Python/test/test_version.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | 4 | class TestModuleBase(unittest.TestCase): 5 | 6 | def test_version(self): 7 | try: 8 | from ccpi.viewer import version as dversion 9 | a = dversion.version 10 | print("version", a) 11 | self.assertTrue(isinstance(a, str)) 12 | except ImportError as ie: 13 | self.assertFalse(True, str(ie)) 14 | -------------------------------------------------------------------------------- /Wrappers/Python/test/test_viewer_main_windows.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import unittest 4 | from unittest import mock 5 | 6 | import vtk 7 | from ccpi.viewer.CILViewer import CILViewer 8 | from ccpi.viewer.CILViewer2D import CILViewer2D 9 | from ccpi.viewer.ui.main_windows import ViewerMainWindow 10 | from PySide2.QtWidgets import QApplication 11 | 12 | # skip the tests on GitHub actions 13 | if os.environ.get('CONDA_BUILD', '0') == '1': 14 | skip_as_conda_build = True 15 | else: 16 | skip_as_conda_build = False 17 | 18 | print("skip_as_conda_build is set to ", skip_as_conda_build) 19 | 20 | 21 | @unittest.skipIf(skip_as_conda_build, "On conda builds do not do any test with interfaces") 22 | class TestViewerMainWindow(unittest.TestCase): 23 | ''' Methods which have their full functionality tested: 24 | - onAppSettingsDialogAccepted 25 | - setDefaultDownsampledSize 26 | - getDefaulDownsampledSize 27 | - getTargetImageSize 28 | - updateViewerCoords 29 | ''' 30 | 31 | def setUp(self): 32 | pass 33 | 34 | def tearDown(self) -> None: 35 | pass 36 | 37 | def test_all(self): 38 | # https://stackoverflow.com/questions/5387299/python-unittest-testcase-execution-order 39 | # https://stackoverflow.com/questions/11145583/unit-and-functional-testing-a-pyside-based-application 40 | self._test_init() 41 | self._test_init_calls_super_init() 42 | self._test_createAppSettingsDialog_calls_setAppSettingsDialogWidgets() 43 | self._test_acceptViewerSettings_when_gpu_checked() 44 | self._test_acceptViewerSettings_when_gpu_unchecked() 45 | self._test_setDefaultDownsampledSize() 46 | self._test_getDefaultDownsampledSize() 47 | self._test_getTargetImageSize_when_vis_size_is_None() 48 | self._test_getTargetImageSize_when_vis_size_is_not_None() 49 | self._test_updateViewerCoords_with_display_unsampled_coords_selected() 50 | self._test_updateViewerCoords_with_display_downsampled_coords_selected() 51 | self._test_updateViewerCoords_with_3D_viewer() 52 | self._test_updateViewerCoords_with_no_img3D() 53 | 54 | def _test_init(self): 55 | vmw = ViewerMainWindow(title="Testing Title", app_name="testing app name") 56 | assert vmw is not None 57 | 58 | def _test_init_calls_super_init(self): 59 | vmw = ViewerMainWindow(title="Testing Title", app_name="testing app name") 60 | # If the super init is called, then the following attributes should be set: 61 | assert vmw.app_name == "testing app name" 62 | assert vmw.threadpool is not None 63 | assert vmw.progress_windows == {} 64 | 65 | def _test_createAppSettingsDialog_calls_setAppSettingsDialogWidgets(self): 66 | vmw = ViewerMainWindow(title="Testing Title", app_name="testing app name") 67 | vmw.setAppSettingsDialogWidgets = mock.MagicMock() 68 | vmw.setViewerSettingsDialogWidgets = mock.MagicMock() 69 | vmw.createAppSettingsDialog() 70 | vmw.setAppSettingsDialogWidgets.assert_called_once() 71 | vmw.setViewerSettingsDialogWidgets.assert_called_once() 72 | 73 | def _test_acceptViewerSettings_when_gpu_checked(self): 74 | 75 | vmw = self._setup_acceptViewerSettings_tests() 76 | 77 | vmw.acceptViewerSettings() 78 | 79 | vmw.settings.assert_has_calls( 80 | [mock.call.setValue('use_gpu_volume_mapper', True), 81 | mock.call.setValue('vis_size', 1.0)], any_order=True) 82 | 83 | assert isinstance(vmw.viewers[0].volume_mapper, vtk.vtkSmartVolumeMapper) 84 | 85 | def _test_acceptViewerSettings_when_gpu_unchecked(self): 86 | 87 | vmw, settings_dialog = self._setup_acceptViewerSettings_tests() 88 | 89 | vmw.acceptViewerSettings(settings_dialog) 90 | 91 | vmw.settings.assert_has_calls( 92 | [mock.call.setValue('use_gpu_volume_mapper', False), 93 | mock.call.setValue('vis_size', 1.0)], any_order=True) 94 | 95 | assert isinstance(vmw.viewers[0].volume_mapper, vtk.vtkFixedPointVolumeRayCastMapper) 96 | 97 | def _setup_acceptViewerSettings_tests(self): 98 | vmw = ViewerMainWindow(title="Testing Title", app_name="testing app name") 99 | vmw.settings = mock.MagicMock() 100 | vmw.settings.setValue = mock.MagicMock() 101 | vis_size_field = mock.MagicMock() 102 | vis_size_field.value.return_value = 1.0 103 | gpu_checkbox_field = mock.MagicMock() 104 | gpu_checkbox_field.isChecked.return_value = True 105 | dark_checkbox_field = mock.MagicMock() 106 | dark_checkbox_field.isChecked.return_value = False 107 | settings_dialog = mock.MagicMock() 108 | settings_dialog.widgets = { 109 | 'vis_size_field': vis_size_field, 110 | 'gpu_checkbox_field': gpu_checkbox_field, 111 | 'dark_checkbox_field': dark_checkbox_field 112 | } 113 | viewer3D = CILViewer() 114 | viewer3D.volume_mapper = None 115 | vmw._viewers = [viewer3D] 116 | vmw._vs_dialog = settings_dialog 117 | return vmw 118 | 119 | def _test_acceptViewerSettings_when_gpu_unchecked(self): 120 | vmw = self._setup_acceptViewerSettings_tests() 121 | vmw._vs_dialog.widgets['gpu_checkbox_field'].isChecked.return_value = False 122 | 123 | vmw.acceptViewerSettings() 124 | vmw.settings.assert_has_calls( 125 | [mock.call.setValue('use_gpu_volume_mapper', False), 126 | mock.call.setValue('vis_size', 1.0)], any_order=True) 127 | 128 | assert isinstance(vmw.viewers[0].volume_mapper, vtk.vtkFixedPointVolumeRayCastMapper) 129 | 130 | def _test_setDefaultDownsampledSize(self): 131 | vmw = ViewerMainWindow(title="Testing Title", app_name="testing app name") 132 | vmw.setDefaultDownsampledSize(5) 133 | assert vmw.default_downsampled_size == 5 134 | 135 | def _test_getDefaultDownsampledSize(self): 136 | vmw = ViewerMainWindow(title="Testing Title", app_name="testing app name") 137 | # Test what the default value is: 138 | assert vmw.getDefaultDownsampledSize() == 512**3 139 | vmw.default_downsampled_size = 5 140 | assert vmw.getDefaultDownsampledSize() == 5 141 | 142 | def _test_getTargetImageSize_when_vis_size_is_None(self): 143 | vmw = ViewerMainWindow(title="Testing Title", app_name="testing app name") 144 | vmw.settings.setValue("vis_size", None) 145 | vmw.getDefaultDownsampledSize = mock.MagicMock() 146 | vmw.getDefaultDownsampledSize.return_value = 512**3 147 | returned_target_size = vmw.getTargetImageSize() 148 | vmw.getDefaultDownsampledSize.assert_called_once() 149 | assert (returned_target_size == 512**3) 150 | 151 | def _test_getTargetImageSize_when_vis_size_is_not_None(self): 152 | vmw = ViewerMainWindow(title="Testing Title", app_name="testing app name") 153 | vmw.settings.setValue("vis_size", 5) 154 | vmw.getDefaultDownsampledSize = mock.MagicMock() 155 | returned_target_size = vmw.getTargetImageSize() 156 | vmw.getDefaultDownsampledSize.assert_not_called() 157 | assert (returned_target_size == 5 * (1024**3)) 158 | 159 | def _test_updateViewerCoords_with_display_unsampled_coords_selected(self): 160 | vmw = ViewerMainWindow(title="Testing Title", app_name="testing app name") 161 | viewer2D = CILViewer2D() 162 | viewer2D.visualisation_downsampling = [2, 2, 2] 163 | viewer2D.img3D = vtk.vtkImageData() 164 | viewer2D.setDisplayUnsampledCoordinates = mock.MagicMock() 165 | viewer2D.updatePipeline = mock.MagicMock() 166 | vmw.viewer_coords_dock.viewers = [viewer2D] 167 | viewer_coords_widgets = vmw.viewer_coords_dock.getWidgets() 168 | viewer_coords_widgets['coords_combo_field'].setCurrentIndex(0) 169 | viewer_coords_widgets['coords_warning_field'].setVisible = mock.MagicMock() 170 | vmw.updateViewerCoords() 171 | 172 | # Expect display unsampled coords to be called with True 173 | # and the warning field to be visible: 174 | viewer2D.setDisplayUnsampledCoordinates.assert_called_once_with(True) 175 | viewer_coords_widgets['coords_warning_field'].setVisible.assert_called_once_with(True) 176 | viewer2D.updatePipeline.assert_called_once() 177 | 178 | viewer2D.updatePipeline = mock.MagicMock() # reset call count 179 | viewer2D.visualisation_downsampling = [1, 1, 1] 180 | vmw.updateViewerCoords() 181 | # Expect the warning field to be hidden: 182 | viewer_coords_widgets['coords_warning_field'].setVisible.assert_called_with(False) 183 | viewer2D.updatePipeline.assert_called_once() 184 | 185 | def _test_updateViewerCoords_with_display_downsampled_coords_selected(self): 186 | vmw = ViewerMainWindow(title="Testing Title", app_name="testing app name") 187 | viewer2D = CILViewer2D() 188 | viewer2D.visualisation_downsampling = [2, 2, 2] 189 | viewer2D.img3D = vtk.vtkImageData() 190 | viewer2D.setDisplayUnsampledCoordinates = mock.MagicMock() 191 | viewer2D.updatePipeline = mock.MagicMock() 192 | vmw.viewer_coords_dock.viewers = [viewer2D] 193 | viewer_coords_widgets = vmw.viewer_coords_dock.getWidgets() 194 | viewer_coords_widgets['coords_combo_field'].setCurrentIndex(1) 195 | viewer_coords_widgets['coords_warning_field'].setVisible = mock.MagicMock() 196 | vmw.updateViewerCoords() 197 | 198 | # Expect display unsampled coords to be called with False 199 | # and the warning field to be hidden: 200 | viewer2D.setDisplayUnsampledCoordinates.assert_called_with(False) 201 | viewer_coords_widgets['coords_warning_field'].setVisible.assert_called_once_with(False) 202 | viewer2D.updatePipeline.assert_called() 203 | 204 | def _test_updateViewerCoords_with_3D_viewer(self): 205 | vmw = ViewerMainWindow(title="Testing Title", app_name="testing app name") 206 | viewer3D = CILViewer() 207 | viewer3D.visualisation_downsampling = [2, 2, 2] 208 | viewer3D.updatePipeline = mock.MagicMock() 209 | viewer3D.setDisplayUnsampledCoordinates = mock.MagicMock() 210 | vmw.viewer_coords_dock.viewers = [viewer3D] 211 | viewer_coords_widgets = vmw.viewer_coords_dock.getWidgets() 212 | viewer_coords_widgets['coords_combo_field'].setCurrentIndex(1) 213 | viewer_coords_widgets['coords_warning_field'].setVisible = mock.MagicMock() 214 | vmw.updateViewerCoords() 215 | 216 | # Expect we don't call anything: 217 | viewer3D.setDisplayUnsampledCoordinates.assert_not_called() 218 | viewer_coords_widgets['coords_warning_field'].setVisible.assert_not_called() 219 | viewer3D.updatePipeline.assert_not_called() 220 | 221 | def _test_updateViewerCoords_with_no_img3D(self): 222 | vmw = ViewerMainWindow(title="Testing Title", app_name="testing app name") 223 | viewer2D = CILViewer2D() 224 | viewer2D.visualisation_downsampling = [2, 2, 2] 225 | viewer2D.img3D = None 226 | viewer2D.setDisplayUnsampledCoordinates = mock.MagicMock() 227 | viewer2D.updatePipeline = mock.MagicMock() 228 | vmw.viewer_coords_dock.viewers = [viewer2D] 229 | viewer_coords_widgets = vmw.viewer_coords_dock.getWidgets() 230 | viewer_coords_widgets['coords_combo_field'].setCurrentIndex(1) 231 | viewer_coords_widgets['coords_warning_field'].setVisible = mock.MagicMock() 232 | vmw.updateViewerCoords() 233 | 234 | # Expect we don't call anything: 235 | viewer2D.setDisplayUnsampledCoordinates.assert_not_called() 236 | viewer_coords_widgets['coords_warning_field'].setVisible.assert_not_called() 237 | viewer2D.updatePipeline.assert_not_called() 238 | 239 | 240 | if __name__ == '__main__': 241 | unittest.main() 242 | -------------------------------------------------------------------------------- /Wrappers/Python/test/test_vtk_image_resampler.py: -------------------------------------------------------------------------------- 1 | from ccpi.viewer.utils.conversion import Converter 2 | from ccpi.viewer.utils.conversion import vtkImageResampler 3 | import unittest 4 | import numpy as np 5 | 6 | 7 | def calculate_target_downsample_shape(max_size, total_size, shape, acq=False): 8 | if not acq: 9 | xy_axes_magnification = np.power(max_size / total_size, 1 / 3) 10 | slice_per_chunk = int(1 / xy_axes_magnification) 11 | else: 12 | slice_per_chunk = 1 13 | xy_axes_magnification = np.power(max_size / total_size, 1 / 2) 14 | num_chunks = 1 + len([i for i in range(slice_per_chunk, shape[2], slice_per_chunk)]) 15 | 16 | target_image_shape = (int(xy_axes_magnification * shape[0]), int(xy_axes_magnification * shape[1]), num_chunks) 17 | return target_image_shape 18 | 19 | 20 | class TestVTKImageResampler(unittest.TestCase): 21 | 22 | def setUp(self): 23 | # Generate random 3D array and convert to VTK Image Data: 24 | np.random.seed(1) 25 | bits = 16 26 | self.bytes_per_element = int(bits / 8) 27 | self.input_3D_array = np.random.randint(10, size=(50, 10, 60), dtype=eval(f"np.uint{bits}")) 28 | self.input_vtk_image = Converter.numpy2vtkImage(self.input_3D_array) 29 | 30 | def test_vtk_resample_reader(self): 31 | # Tests image with correct target size is generated by resample reader: 32 | # Not a great test, but at least checks the resample reader runs 33 | # without crashing 34 | # TODO: improve this test 35 | reader = vtkImageResampler() 36 | reader.SetInputDataObject(self.input_vtk_image) 37 | target_size = 100 38 | reader.SetTargetSize(target_size) 39 | reader.Update() 40 | 41 | image = reader.GetOutput() 42 | extent = image.GetExtent() 43 | og_shape = np.shape(self.input_3D_array) 44 | resulting_shape = (extent[1] + 1, (extent[3] + 1), (extent[5] + 1)) 45 | og_shape = (og_shape[2], og_shape[1], og_shape[0]) 46 | og_size = og_shape[0] * og_shape[1] * og_shape[2] * self.bytes_per_element 47 | expected_shape = calculate_target_downsample_shape(target_size, og_size, og_shape) 48 | self.assertEqual(resulting_shape, expected_shape) 49 | 50 | # # Now test if we get the full image extent if our 51 | # # target size is larger than the size of the image: 52 | target_size = og_size * 2 53 | reader.SetTargetSize(target_size) 54 | reader.Update() 55 | image = reader.GetOutput() 56 | extent = image.GetExtent() 57 | expected_shape = og_shape 58 | resulting_shape = (extent[1] + 1, (extent[3] + 1), (extent[5] + 1)) 59 | self.assertEqual(resulting_shape, expected_shape) 60 | resulting_array = Converter.vtk2numpy(image) 61 | np.testing.assert_array_equal(self.input_3D_array, resulting_array) 62 | 63 | # # Now test if we get the correct z extent if we set that we 64 | # # have acquisition data 65 | target_size = 100 66 | reader.SetTargetSize(target_size) 67 | reader.SetIsAcquisitionData(True) 68 | reader.Update() 69 | image = reader.GetOutput() 70 | extent = image.GetExtent() 71 | shape_not_acquisition = calculate_target_downsample_shape(target_size, og_size, og_shape, acq=True) 72 | expected_size = shape_not_acquisition[0] * \ 73 | shape_not_acquisition[1]*shape_not_acquisition[2] 74 | resulting_shape = (extent[1] + 1, (extent[3] + 1), (extent[5] + 1)) 75 | resulting_size = resulting_shape[0] * \ 76 | resulting_shape[1]*resulting_shape[2] 77 | # angle (z direction) is first index in numpy array, and in cil 78 | # but it is the last in vtk. 79 | resulting_z_shape = extent[5] + 1 80 | og_z_shape = np.shape(self.input_3D_array)[0] 81 | self.assertEqual(resulting_size, expected_size) 82 | self.assertEqual(resulting_z_shape, og_z_shape) 83 | 84 | 85 | if __name__ == '__main__': 86 | unittest.main() 87 | -------------------------------------------------------------------------------- /Wrappers/javascript/test_vtk.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 22 | 23 | -------------------------------------------------------------------------------- /docker/web-app/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG BASE_IMAGE=ubuntu:20.04 2 | FROM ${BASE_IMAGE} as base 3 | 4 | LABEL \ 5 | author.name="Edoardo Pasca, Laura Murgatroyd , Samuel Jones" \ 6 | author.email=edoardo.pasca@stfc.ac.uk \ 7 | maintainer.email=edoardo.pasca@stfc.ac.uk \ 8 | maintainer.url=https://www.ccpi.ac.uk/ \ 9 | source.url=https://github.com/vais-ral/CILViewer \ 10 | licence="Apache License 2.0 (https://www.apache.org/licenses/LICENSE-2.0)" \ 11 | description="CILViewer Ubuntu" 12 | 13 | ENV LANG en_GB.UTF-8 14 | ENV LANGUAGE en_GB:en 15 | 16 | ARG USER="abc" 17 | ARG GROUP="users" 18 | ARG HOME="/home/${USER}" 19 | WORKDIR ${HOME} 20 | 21 | USER root 22 | RUN apt-get update -y && \ 23 | apt-get upgrade -y && \ 24 | apt-get install curl git -y && \ 25 | # Add VTK dependencies and xvfb for running displays in a container 26 | apt-get install libgl1 libxrender1 libgl1-mesa-dev xvfb -y 27 | 28 | USER ${NB_USER}:${NB_GROUP} 29 | 30 | COPY environment.yml . 31 | COPY install-mambaforge-and-env.sh . 32 | RUN bash install-mambaforge-and-env.sh 33 | RUN rm install-mambaforge-and-env.sh 34 | RUN rm environment.yml 35 | 36 | COPY clone-and-install.sh . 37 | RUN bash clone-and-install.sh 38 | RUN rm clone-and-install.sh 39 | 40 | COPY create-data-folder.sh . 41 | RUN bash create-data-folder.sh 42 | RUN rm create-data-folder.sh 43 | 44 | COPY entrypoint.sh /usr/local/bin/ 45 | ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] 46 | # set default parameter for entrypoint 47 | #https://docs.docker.com/engine/reference/builder/#cmd 48 | CMD ["/home/abc/data"] 49 | 50 | -------------------------------------------------------------------------------- /docker/web-app/README.md: -------------------------------------------------------------------------------- 1 | To build the docker image run this command, with the working directory being the directory of this README: 2 | `docker build .` 3 | 4 | To find the image id, look at the image list using: 5 | `docker image list` 6 | 7 | Using the most recently created docker image as the image ID: 8 | `docker run -p 8080:8080 ` 9 | 10 | The container should start with a web server running an individual client's CILViewer instance on 0.0.0.0:8080, and the listed docker IP address in the attached console. 11 | 12 | To bind a directory with data on the host one could launch 13 | 14 | `docker run -p 8080:8080 --mount type=bind,source="$(pwd)"/volume,target=/home/abc/bind /home/abc/bind` 15 | -------------------------------------------------------------------------------- /docker/web-app/clone-and-install.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | git clone https://github.com/vais-ral/CILViewer 4 | 5 | . /home/abc/mambaforge/etc/profile.d/conda.sh 6 | conda activate cilviewer_webapp 7 | cd CILViewer/Wrappers/Python 8 | pip install . 9 | 10 | cd ../../.. 11 | rm -rf CILViewer -------------------------------------------------------------------------------- /docker/web-app/create-data-folder.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | mkdir /home/abc/data 4 | curl -L -O https://github.com/TomographicImaging/CIL-Data/raw/5affe9b1c3bd20b28aee7756aa968d7c2a9eeff4/head.mha 5 | mv head.mha /home/abc/data -------------------------------------------------------------------------------- /docker/web-app/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | . /home/abc/mambaforge/etc/profile.d/conda.sh 4 | conda activate cilviewer_webapp 5 | 6 | xvfb-run -a web_cilviewer $1 --host 0.0.0.0 --server 7 | -------------------------------------------------------------------------------- /docker/web-app/environment.yml: -------------------------------------------------------------------------------- 1 | name: cilviewer_webapp 2 | channels: 3 | - conda-forge 4 | dependencies: 5 | - python==3.9 6 | - matplotlib # Optional for more colormaps 7 | - h5py 8 | - pip 9 | - schema 10 | - pyyaml 11 | - pip: 12 | # Have to install Trame via pip due to unavailability on conda 13 | - trame <3, >=2.1.1 # Unpinned worked with version 2.1.1, should work with higher versions. 14 | - vtk==9.1 15 | -------------------------------------------------------------------------------- /docker/web-app/install-mambaforge-and-env.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # https://stackoverflow.com/a/246128 4 | SCRIPT_DIR="$( cd -- "$( dirname -- "${BASH_SOURCE[0]:-$0}"; )" &> /dev/null && pwd 2> /dev/null; )"; 5 | INSTALL_DIR=$HOME/mambaforge 6 | 7 | cd $HOME 8 | curl -L -O "https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-$(uname)-$(uname -m).sh" 9 | bash $HOME/Miniforge3-$(uname)-$(uname -m).sh -b -p $INSTALL_DIR 10 | rm $HOME/Miniforge3-Linux-x86_64.sh 11 | . $INSTALL_DIR/etc/profile.d/conda.sh 12 | mamba env create -f $SCRIPT_DIR/environment.yml 13 | conda activate cilviewer_webapp 14 | --------------------------------------------------------------------------------