diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index 5bf4860b1..000000000 --- a/.editorconfig +++ /dev/null @@ -1,26 +0,0 @@ -root = true - -[*] -charset = utf-8 -insert_final_newline = true -trim_trailing_whitespace = true - -[*.md] -indent_size = 2 -indent_style = space -max_line_length = 100 # Please keep this in sync with bin/lesson_check.py! -trim_trailing_whitespace = false # keep trailing spaces in markdown - 2+ spaces are translated to a hard break (
) - -[*.r] -max_line_length = 80 - -[*.py] -indent_size = 4 -indent_style = space -max_line_length = 79 - -[*.sh] -end_of_line = lf - -[Makefile] -indent_style = tab diff --git a/.github/workbench-docker-version.txt b/.github/workbench-docker-version.txt deleted file mode 100644 index 34707cbb1..000000000 --- a/.github/workbench-docker-version.txt +++ /dev/null @@ -1 +0,0 @@ -v0.2.7 diff --git a/.github/workflows/README.md b/.github/workflows/README.md deleted file mode 100755 index 59a486100..000000000 --- a/.github/workflows/README.md +++ /dev/null @@ -1,260 +0,0 @@ -# Workflow Documentation - -## Managing Workflow Updates - -By using prebuilt Docker containers that are managed by the Carpentries core Workbench maintainers, these workflows are designed to be rarely updated. - -However, is important to be able to keep them up-to-date when appropriate. -You can do this locally using your own R and Workbench installation, or via the "04 Maintain: Update Workflow Files" (`update-workflows.yaml`) GitHub Action. - -### Updating locally - -In a terminal/git bash, navigate to the lesson folder where you want to update the workflows. - -Then, start an R session and: - -```r -# Install/Update sandpaper -options(repos = c(carpentries = "https://carpentries.r-universe.dev/", CRAN = "https://cloud.r-project.org")) -install.packages("sandpaper") - -# update the workflows in your lesson -library("sandpaper") -sandpaper::update_github_workflows() -quit() -``` - -And then in a bash prompt/git bash terminal: - -```bash -$ git add .github/workflows -$ git commit -m "Manual update to docker workflows" -$ git push origin main -``` - -> [!NOTE] -> For non-renv lessons, this is all the setup you need! -> -> For renv-enabled lessons: -> - Cancel any "01 Maintain: Build and Deploy Site" workflow currently running -> - Run the "02 Maintain: Check for Updated Packages" workflow and merge any PR opened to update the renv lockfile -> - This should automatically run the "03 Maintain: Apply Package Cache" workflow to install packages and build the cache -> - A successful cache buid should then trigger the "01 Maintain: Build and Deploy Site" workflow - -### Updating using GitHub - -#### Official lessons - -"Official" lessons are those in the lesson program repositories, Incubator, or Lab. -They need no extra setup as this is all managed for you as part of the Carpentries GitHub organisations. - -To update the workflows, either: -- wait for the scheduled run of the "04 Maintain: Update Workflow Files" at approximately midnight every Tuesday -- go to the Actions tab on GitHub, click "04 Maintain: Update Workflow Files" on the left, then "Run Workflow" on the right - -Once complete, this will raise a PR with any changes to the workflows that are needed. -If you are happy with the changes made, you can merge the PR into your lesson repository. - -#### Your own lessons - -This presumes you: - - already have a lesson repository available on GitHub - - have enabled workflows in the lesson repo - - have set up a SANDPAPER_WORKFLOW personal access token (PAT) in the lesson repo - -To go through these steps, please follow the [Forking a Workbench Lesson](https://docs.carpentries.org/resources/curriculum/lesson-forks.html#forking-a-workbench-lesson-repository) -documentation. - -Once set up, run the "04 Maintain: Update Workflow Files" (`update-workflows.yaml`) action. - -This will raise a PR with any changes to the workflows that are needed. -If you are happy with the changes made, you can merge the PR into your lesson repository. - - -## Package Caches for RMarkdown Lessons - -In summary, generating a reusable package cache is achieved by running the "02 Maintain: Check for Updated Packages" workflow, and then the "03 Maintain: Apply Package Cache" workflow. - -> [!NOTE] -> Caching is only relevant for lessons that use Rmd files and renv to manage R packages. -> If you are building basic markdown documents, caching will not apply to you, and the only -> workflow that needs to be run is "01 Maintain: Build and Deploy Site". - -### Caching - -The two cache management workflows are separated to ensure that once you have a successful build with a working renv cache, this cache is stored and will be reused by the Workbench Docker container. -This means that lesson builds will be faster once an renv cache is created and reused by the Docker container. - -Another major bonus of this setup is that you can keep using this cache indefinitely to build your lesson. -This is important if you need very specific versions of R packages ("pinning"). - -If and when you want to perform an update to the cache, you can re-run the "02 Maintain: Check for Updated Packages" and verify that your lesson still builds with the new packages. -If all looks good, re-run the "03 Maintain: Apply Package Cache" workflow, and this will write a new renv cache file to GitHub. - -In any case, the renv cache is invalidated by new versions of the `renv.lock` file. -This happens: - - if you update your lockfile locally by using the `sandpaper::update_cache()` function, and then push it to the lesson repository - - when you run the "02 Maintain: Check for Updated Packages" and there are new packages to install - -More information on managing local renv caches for lessons can be found in the [Sandpaper packages vignettes](https://carpentries.github.io/sandpaper/articles/building-with-renv.html). - -#### Using different package cache versions - -There are times when you may want to go back to a previous renv package cache file: - - if you run "02 Maintain: Check for Updated Packages" and "03 Maintain: Apply Package Cache" and the cache generation fails for some reason - - if there is a new R package that produces incorrect or broken lesson output - -Cache files will have the following name format, where IMAGE is the workbench-docker image version, and HASHSUM is the `renv.lock` lockfile MD5 hash: - -``` -IMAGE HASHSUM -[ | ] [ | ] -v0.2.4_renv-2e499eb706112971b2cffceb49b55a6efe49f3ed75cd6579b10ff224489daca4 -``` - -Copy the hashsum part of the desired cache file you want to use, e.g. `2e499eb706112971b2cffceb49b55a6efe49f3ed75cd6579b10ff224489daca4`. - -Then either: - 1. Add a repository variable called CACHE_VERSION, and paste in the hash - - Go to ... - 2. Run the "01 Maintain: Build and Deploy Site" manually, supplying the CACHE_VERSION input - - Go to ... - -If you have no caches listed, make sure to run the "02 Maintain: Check for Updated Packages" and "03 Maintain: Apply Package Cache" to create a new renv cache file. - -> [!NOTE] -> If you are maintaining an official lesson, caches are saved in an AWS S3 bucket owned by the Carpentries. -> Once a successful cache has been saved, these will be listed in the outputs of the "01 Maintain: Build and Deploy Site" workflow. -> -> If you are developing a lesson in your own repository, caches are saved on GitHub. -> You can see available caches by going to the Actions tab, and clicking Caches on the left hand side. - - -## User Settings - -Input level variables are documented in the `carpentries/actions` repository READMEs for each composite action. - -Specific repository level variables can be set that will force particular options across all workflow runs. - -### 01 Maintain: Build and Deploy Site (docker_build_deploy.yaml) - -Repository-level variables for this workflow are: -- WORKBENCH_TAG - - The workbench-docker release version to use for a given build - - This can be set to a specific version number to force all builds to use a given container version - - Default is unset or `latest` -- BUILD_RESET - - Force a reset of previously build markdown files - - Setting this variable value to `true` will force sandpaper to delete any previously build markdown files - - Default is unset or `false` -- AUTO_MERGE_WORKBENCH_VERSION_UPDATE - - Control merge behaviour of the workbench-docker version update PR - - When a new workbench Docker image version is detected, usually after a sandpaper, varnish, or pegboard update, its version number will be incremented - - If a newer version is available, a PR will be auto-generated that updates the `.github/workbench-docker-version.txt` file, and this PR will be auto-merged - - To not auto-merge this PR and to choose when to update the Docker version used, set this to `false`. - - Default is unset or `true` -- LANG_CODE - - Two-letter language code that triggers the use of Joel Nitta's {dovetail} package for lesson translation - - This is used in the internationalisation repos of the main Carpentry lesson programs - - Default is unset or `''` - -### 02 Maintain: Check for Updated Packages (update-cache.yaml) - -Repository-level variables for this workflow are: -- LOCKFILE_CACHE_GEN - - Passed to the `generate-cache` input of the [update-lockfile](https://github.com/carpentries/actions/tree/main/update-lockfile) action - - A temporary renv cache is generated when this workflow runs - - If this option is set to `false`, no temporary cache will be generated - - Default is `true` -- FORCE_RENV_INIT - - Passed to the `force-renv-init` input of the [update-lockfile](https://github.com/carpentries/actions/tree/main/update-lockfile) action - - renv initialises a cache based on a given lockfile - - If this lockfile is particularly old or packages have broken/unresolvable dependencies, then builds will fail - - If this option is set to `true`, a full renv reinitialisation will occur, "wiping the slate clean" - - This option is useful if you're using Bioconductor packages which often break when new Bioconductor releases happen - - Default is `false` -- UPDATE_PACKAGES - - Passed to the `update` input of the [update-lockfile](https://github.com/carpentries/actions/tree/main/update-lockfile) action - - If set to `false` only package hydration will happen and no package update checks will occur - - Default is `true` - -### 03 Maintain: Apply Package Cache (docker_apply_cache.yaml) - -Repository-level variables for this workflow are: -- WORKBENCH_TAG - - The workbench-docker release version to use for a given build - - This can be set to a specific version number to force all builds to use a given container version - - Default is unset or `latest` - - -### 04 Maintain: Update Workflow Files (update-workflows.yaml) - -There are no repository variables for this workflow. - - -## Pull Request and Review Management - -Because our lessons execute code, pull requests are a security risk for any lesson and thus have security measures associted with them. -**Do not merge any pull requests that do not pass checks and do not have bots commented on them.** - -This series of workflows all go together and are described in the following diagram and the below sections: - -![Graph representation of a pull request](https://carpentries.github.io/sandpaper/articles/img/pr-flow.dot.svg) - -### Pre Flight Pull Request Validation (pr-preflight.yaml) - -This workflow runs every time a pull request is created and its purpose is to validate that the pull request is okay to run. -This means the following things: - -1. The pull request does not contain modified workflow files -2. If the pull request contains modified workflow files, it does not contain modified content files - (such as a situation where @carpentries-bot will make an automated pull request) -3. The pull request does not contain an invalid commit hash - (e.g. from a fork that was made before a lesson was transitioned from styles to use the Workbench). - -Once the checks are finished, a comment is issued to the pull request, which will allow maintainers to determine if it is safe to run the "Receive Pull Request" workflow from new contributors. - -### Receive Pull Request (docker_pr_receive.yaml) - -**Note of caution:** This workflow runs arbitrary code by anyone who creates a pull request. -GitHub has safeguarded the token used in this workflow to have no privileges in the repository, but we have taken precautions to protect against spoofing. - -This workflow is triggered with every push to a pull request. -If this workflow is already running and a new push is sent to the pull request, the workflow running from the previous push will be cancelled and a new workflow run will be started. - -The first step of this workflow is to check if it is valid (e.g. that no workflow files have been modified): -- If there are workflow files that have been modified, a comment is made that indicates that the workflow will not continue. -- If both a workflow file and lesson content is modified, an error will occur and the workflow will not continue. - -The second step (if valid) is to build the generated content from the pull request. -This builds the content and uploads three artifacts: - -1. The pull request number (pr) -2. A summary of changes after the rendering process (diff) -3. The rendered files (build) - -The artifacts produced are used by the "Comment on Pull Request" workflow. - -### Comment on Pull Request (pr-comment.yaml) - -This workflow is triggered if the `docker_pr_receive.yaml` workflow is successful. -The steps in this workflow are: - -1. Test if the workflow is valid and comment the validity of the workflow to the pull request. -2. If it is valid: create an orphan branch with two commits: the current state of the repository and the proposed changes. -3. If it is valid: update the pull request comment with the summary of changes - -Importantly: if the pull request is invalid, the branch is not created so any malicious code is not published. - -From here, the maintainer can request changes from the author and eventually either merge or reject the PR. -When this happens, if the PR was valid, the preview branch needs to be deleted. - -### Send Close PR Signal (pr-close-signal.yaml) - -Triggered any time a pull request is closed. -This emits an artifact that is the pull request number for the next action. - -### Remove Pull Request Branch (pr-post-remove-branch.yaml) - -Tiggered by `pr-close-signal.yaml`. -This removes the temporary branch associated with the pull request (if it was created). diff --git a/.github/workflows/close-pr.yaml b/.github/workflows/close-pr.yaml new file mode 100755 index 000000000..e895432f1 --- /dev/null +++ b/.github/workflows/close-pr.yaml @@ -0,0 +1,61 @@ +name: "Pull Request in wrong branch" + +on: + pull_request_target: + types: + ["opened", "synchronize", "reopened"] + +jobs: + close-pr: + permissions: + pull-requests: write + name: "Inform of Workbench and Close" + if: ${{ github.event.action != 'closed' }} + runs-on: ubuntu-latest + steps: + - name: Provide Guidance + id: comment-diff + if: ${{ always() }} + uses: carpentries/actions/comment-diff@main + with: + pr: ${{ github.event.number }} + body: > + # :no_entry_sign: The `gh-pages` branch is no longer editable :no_entry_sign: + + Thank you for your contribution. This lesson has migrated to use + [The Carpentries Workbench](https://carpentries.github.io/workbench) + and the `gh-pages` branch is now automatically generated. This + means in order to contribute, **you will need to delete and re-fork + this repository.** + + ## How to contribute + + If you wish to contribute, you will need to use the following steps + to delete, re-fork, and re-create your pull request (aka the [burn + it all down strategy](https://happygitwithr.com/burn.html)): + + 1. Save your edits on locally or in a scratch space. + + 2. **[Delete your fork](https://docs.github.com/en/repositories/creating-and-managing-repositories/deleting-a-repository)** + + 3. **[Create a new fork](https://docs.github.com/en/get-started/quickstart/fork-a-repo)** or use the "edit" button on the page you wish to edit. + + 4. Apply your changes (**NOTE The Workbench uses a different syntax.** Here is a [Transition Guide from Styles to Workbench](https://carpentries.github.io/workbench/transition-guide.html) for your reference). + + ## Questions + + If you have any questions or would like assistance, please contact + @core-team-curriculum (curriculum@carpentries.org) or you can + respond to this message. + + - name: Close Pull Request + uses: actions/github-script@v6 + if: ${{ always() }} + with: + script: | + github.rest.pulls.update({ + pull_number: context.payload.pull_request.number, + owner: context.repo.owner, + repo: context.repo.repo, + state: 'closed' + }) diff --git a/.github/workflows/docker_apply_cache.yaml b/.github/workflows/docker_apply_cache.yaml deleted file mode 100644 index 0f3a1abb9..000000000 --- a/.github/workflows/docker_apply_cache.yaml +++ /dev/null @@ -1,229 +0,0 @@ -name: "03 Maintain: Apply Package Cache" -description: "Generate the package cache for the lesson after a pull request has been merged or via manual trigger, and cache in S3 or GitHub" -on: - workflow_dispatch: - inputs: - name: - description: 'Who triggered this build?' - required: true - default: 'Maintainer (via GitHub)' - pull_request: - types: - - closed - branches: - - main - -# queue cache runs -concurrency: - group: docker-apply-cache - cancel-in-progress: false - -jobs: - preflight: - name: "Preflight: PR or Manual Trigger?" - runs-on: ubuntu-latest - outputs: - do-apply: ${{ steps.check.outputs.merged_or_manual }} - steps: - - name: "Should we run cache application?" - id: check - run: | - if [[ "${{ github.event_name }}" == "workflow_dispatch" || - ("${{ github.ref }}" == "refs/heads/main" && "${{ github.event.action }}" == "closed" && "${{ github.event.pull_request.merged }}" == "true") ]]; then - echo "merged_or_manual=true" >> $GITHUB_OUTPUT - else - echo "This was not a manual trigger and no PR was merged. No action taken." - echo "merged_or_manual=false" >> $GITHUB_OUTPUT - fi - shell: bash - - check-renv: - name: "Check If We Need {renv}" - runs-on: ubuntu-latest - needs: preflight - if: needs.preflight.outputs.do-apply == 'true' - permissions: - id-token: write - outputs: - renv-needed: ${{ steps.check-for-renv.outputs.renv-needed }} - renv-cache-hashsum: ${{ steps.check-for-renv.outputs.renv-cache-hashsum }} - renv-cache-available: ${{ steps.check-for-renv.outputs.renv-cache-available }} - steps: - - name: "Check for renv" - id: check-for-renv - uses: carpentries/actions/renv-checks@main - with: - role-to-assume: ${{ secrets.AWS_GH_OIDC_ARN }} - aws-region: ${{ secrets.AWS_GH_OIDC_REGION }} - WORKBENCH_TAG: ${{ vars.WORKBENCH_TAG || 'latest' }} - token: ${{ secrets.GITHUB_TOKEN }} - - no-renv-cache-used: - name: "No renv cache used" - runs-on: ubuntu-latest - needs: check-renv - if: needs.check-renv.outputs.renv-needed != 'true' - steps: - - name: "No renv cache needed" - run: echo "No renv cache needed for this lesson" - - renv-cache-available: - name: "renv cache available" - runs-on: ubuntu-latest - needs: check-renv - if: needs.check-renv.outputs.renv-cache-available == 'true' - steps: - - name: "renv cache available" - run: echo "renv cache available for this lesson" - - update-renv-cache: - name: "Update renv Cache" - runs-on: ubuntu-latest - needs: check-renv - if: | - needs.check-renv.outputs.renv-needed == 'true' && - needs.check-renv.outputs.renv-cache-available != 'true' && - ( - github.event_name == 'workflow_dispatch' || - ( - github.event.pull_request.merged == true && - ( - ( - contains( - join(github.event.pull_request.labels.*.name, ','), - 'type: package cache' - ) && - github.event.pull_request.head.ref == 'update/packages' - ) - || - ( - contains( - join(github.event.pull_request.labels.*.name, ','), - 'type: workflows' - ) && - github.event.pull_request.head.ref == 'update/workflows' - ) - || - ( - contains( - join(github.event.pull_request.labels.*.name, ','), - 'type: docker version' - ) && - github.event.pull_request.head.ref == 'update/workbench-docker-version' - ) - ) - ) - ) - permissions: - checks: write - contents: write - pages: write - id-token: write - container: - image: ghcr.io/carpentries/workbench-docker:${{ vars.WORKBENCH_TAG || 'latest' }} - env: - WORKBENCH_PROFILE: "ci" - GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} - RENV_PATHS_ROOT: /home/rstudio/lesson/renv - RENV_PROFILE: "lesson-requirements" - RENV_VERSION: ${{ needs.check-renv.outputs.renv-cache-hashsum }} - RENV_CONFIG_EXTERNAL_LIBRARIES: "/usr/local/lib/R/site-library" - volumes: - - ${{ github.workspace }}:/home/rstudio/lesson - options: --cpus 2 - steps: - - uses: actions/checkout@v6 - - - name: "Debugging Info" - run: | - echo "Current Directory: $(pwd)" - ls -lah /home/rstudio/.workbench - ls -lah $(pwd) - Rscript -e 'sessionInfo()' - shell: bash - - - name: "Mark Repository as Safe" - run: | - git config --global --add safe.directory $(pwd) - shell: bash - - - name: "Ensure sandpaper is loadable" - run: | - .libPaths() - library(sandpaper) - shell: Rscript {0} - - - name: "Setup Lesson Dependencies" - run: | - Rscript /home/rstudio/.workbench/setup_lesson_deps.R - shell: bash - - - name: "Fortify renv Cache" - run: | - Rscript /home/rstudio/.workbench/fortify_renv_cache.R - shell: bash - - - name: "Get Container Version Used" - id: wb-vers - uses: carpentries/actions/container-version@main - with: - WORKBENCH_TAG: ${{ vars.WORKBENCH_TAG }} - renv-needed: ${{ needs.check-renv.outputs.renv-needed }} - token: ${{ secrets.GITHUB_TOKEN }} - - - name: "Validate Current Org and Workflow" - id: validate-org-workflow - uses: carpentries/actions/validate-org-workflow@main - with: - repo: ${{ github.repository }} - workflow: ${{ github.workflow }} - - - name: "Configure AWS credentials via OIDC" - id: aws-creds - env: - role-to-assume: ${{ secrets.AWS_GH_OIDC_ARN }} - aws-region: ${{ secrets.AWS_GH_OIDC_REGION }} - if: | - steps.validate-org-workflow.outputs.is_valid == 'true' && - env.role-to-assume != '' && - env.aws-region != '' - uses: aws-actions/configure-aws-credentials@v6 - with: - role-to-assume: ${{ env.role-to-assume }} - aws-region: ${{ env.aws-region }} - output-credentials: true - - - name: "Upload cache object to S3" - id: upload-cache - uses: tespkg/actions-cache@v1.10.0 - with: - accessKey: ${{ steps.aws-creds.outputs.aws-access-key-id }} - secretKey: ${{ steps.aws-creds.outputs.aws-secret-access-key }} - sessionToken: ${{ steps.aws-creds.outputs.aws-session-token }} - bucket: workbench-docker-caches - path: | - /home/rstudio/lesson/renv - /usr/local/lib/R/site-library - key: ${{ github.repository }}/${{ steps.wb-vers.outputs.container-version }}_renv-${{ needs.check-renv.outputs.renv-cache-hashsum }} - restore-keys: - ${{ github.repository }}/${{ steps.wb-vers.outputs.container-version }}_renv- - - record-cache-result: - name: "Record Caching Status" - runs-on: ubuntu-latest - needs: [check-renv, update-renv-cache] - if: always() - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - steps: - - name: "Record cache result" - - run: | - echo "${{ needs.update-renv-cache.result == 'success' || needs.check-renv.outputs.renv-cache-available == 'true' || 'false' }}" > ${{ github.workspace }}/apply-cache-result - shell: bash - - - name: "Upload cache result" - uses: actions/upload-artifact@v7 - with: - name: apply-cache-result - path: ${{ github.workspace }}/apply-cache-result diff --git a/.github/workflows/docker_build_deploy.yaml b/.github/workflows/docker_build_deploy.yaml deleted file mode 100644 index 4baf306f9..000000000 --- a/.github/workflows/docker_build_deploy.yaml +++ /dev/null @@ -1,161 +0,0 @@ -name: "01 Maintain: Build and Deploy Site" -description: "Build and deploy the lesson site using the carpentries/workbench-docker container" -on: - push: - branches: - - 'main' - - 'l10n_main' - paths-ignore: - - '.github/workflows/**.yaml' - - '.github/workbench-docker-version.txt' - schedule: - - cron: '0 0 * * 2' - workflow_run: - workflows: ["03 Maintain: Apply Package Cache"] - types: - - completed - workflow_dispatch: - inputs: - name: - description: 'Who triggered this build?' - required: true - default: 'Maintainer (via GitHub)' - CACHE_VERSION: - description: 'Optional renv cache version override' - required: false - default: '' - reset: - description: 'Reset cached markdown files' - required: true - default: false - type: boolean - force-skip-manage-deps: - description: 'Skip build-time dependency management' - required: true - default: false - type: boolean - -# only one build/deploy at a time -concurrency: - group: docker-build-deploy - cancel-in-progress: true - -jobs: - preflight: - name: "Preflight: Schedule, Push, or PR?" - runs-on: ubuntu-latest - outputs: - do-build: ${{ steps.build-check.outputs.do-build }} - renv-needed: ${{ steps.build-check.outputs.renv-needed }} - renv-cache-hashsum: ${{ steps.build-check.outputs.renv-cache-hashsum }} - workbench-container-file-exists: ${{ steps.wb-vers.outputs.workbench-container-file-exists }} - wb-vers: ${{ steps.wb-vers.outputs.container-version }} - last-wb-vers: ${{ steps.wb-vers.outputs.last-container-version }} - workbench-update: ${{ steps.wb-vers.outputs.workbench-update }} - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - steps: - - name: "Should we run build and deploy?" - id: build-check - uses: carpentries/actions/build-preflight@main - - - name: "Checkout Lesson" - if: steps.build-check.outputs.do-build == 'true' - uses: actions/checkout@v6 - - - name: "Get container version info" - id: wb-vers - if: steps.build-check.outputs.do-build == 'true' - uses: carpentries/actions/container-version@main - with: - WORKBENCH_TAG: ${{ vars.WORKBENCH_TAG }} - renv-needed: ${{ steps.build-check.outputs.renv-needed }} - token: ${{ secrets.GITHUB_TOKEN }} - - full-build: - name: "Build Full Site" - runs-on: ubuntu-latest - needs: preflight - if: | - needs.preflight.outputs.do-build == 'true' && - needs.preflight.outputs.workbench-update != 'true' - env: - RENV_EXISTS: ${{ needs.preflight.outputs.renv-needed }} - RENV_HASH: ${{ needs.preflight.outputs.renv-cache-hashsum }} - permissions: - checks: write - contents: write - pages: write - id-token: write - container: - image: ghcr.io/carpentries/workbench-docker:${{ vars.WORKBENCH_TAG || 'latest' }} - env: - WORKBENCH_PROFILE: "ci" - GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} - RENV_PATHS_ROOT: /home/rstudio/lesson/renv - RENV_PROFILE: "lesson-requirements" - RENV_CONFIG_EXTERNAL_LIBRARIES: "/usr/local/lib/R/site-library" - volumes: - - ${{ github.workspace }}:/home/rstudio/lesson - options: --cpus 1 - steps: - - uses: actions/checkout@v6 - - - name: "Debugging Info" - run: | - cd /home/rstudio/lesson - echo "Current Directory: $(pwd)" - echo "RENV_HASH is $RENV_HASH" - ls -lah /home/rstudio/.workbench - ls -lah $(pwd) - Rscript -e 'sessionInfo()' - shell: bash - - - name: "Mark Repository as Safe" - run: | - git config --global --add safe.directory $(pwd) - shell: bash - - - name: "Setup Lesson Dependencies" - id: build-container-deps - uses: carpentries/actions/build-container-deps@main - with: - CACHE_VERSION: ${{ vars.CACHE_VERSION || github.event.inputs.CACHE_VERSION || '' }} - WORKBENCH_TAG: ${{ vars.WORKBENCH_TAG || 'latest' }} - LESSON_PATH: ${{ vars.LESSON_PATH || '/home/rstudio/lesson' }} - role-to-assume: ${{ secrets.AWS_GH_OIDC_ARN }} - aws-region: ${{ secrets.AWS_GH_OIDC_REGION }} - token: ${{ secrets.GITHUB_TOKEN }} - - - name: "Run Container and Build Site" - id: build-and-deploy - uses: carpentries/actions/build-and-deploy@main - with: - reset: ${{ vars.BUILD_RESET || github.event.inputs.reset || 'false' }} - skip-manage-deps: ${{ github.event.inputs.force-skip-manage-deps == 'true' || steps.build-container-deps.outputs.renv-cache-available || steps.build-container-deps.outputs.backup-cache-used || 'false' }} - lang-code: ${{ vars.LANG_CODE || '' }} - - update-container-version: - name: "Update container version used" - runs-on: ubuntu-latest - needs: [preflight] - permissions: - actions: write - contents: write - pull-requests: write - id-token: write - if: | - needs.preflight.outputs.do-build == 'true' && - ( - needs.preflight.outputs.workbench-container-file-exists == 'false' || - needs.preflight.outputs.workbench-update == 'true' - ) - steps: - - name: "Record container version used" - uses: carpentries/actions/record-container-version@main - with: - CONTAINER_VER: ${{ needs.preflight.outputs.wb-vers }} - AUTO_MERGE: ${{ vars.AUTO_MERGE_CONTAINER_VERSION_UPDATE || 'true' }} - token: ${{ secrets.GITHUB_TOKEN }} - role-to-assume: ${{ secrets.AWS_GH_OIDC_ARN }} - aws-region: ${{ secrets.AWS_GH_OIDC_REGION }} diff --git a/.github/workflows/docker_pr_receive.yaml b/.github/workflows/docker_pr_receive.yaml deleted file mode 100644 index 486b4b4fb..000000000 --- a/.github/workflows/docker_pr_receive.yaml +++ /dev/null @@ -1,283 +0,0 @@ -name: "Bot: Receive Pull Request" -description: "Receive a pull request and build the markdown source files" -on: - pull_request: - types: - [opened, synchronize, reopened] - workflow_dispatch: - inputs: - pr_number: - type: number - required: true - -concurrency: - group: ${{ github.ref }} - cancel-in-progress: true - -permissions: - contents: read - pull-requests: write - -jobs: - preflight: - name: "Preflight: md-outputs exists?" - runs-on: ubuntu-latest - outputs: - branch-exists: ${{ steps.check.outputs.exists }} - steps: - - name: "Checkout Lesson" - uses: actions/checkout@v6 - - - name: "Check if md-outputs branch exists" - id: check - run: | - # 💡 Checking for md-outputs branch # - if [[ -n $(git ls-remote --exit-code --heads origin md-outputs) ]]; then - echo "exists=true" >> $GITHUB_OUTPUT - else - echo "exists=false" >> $GITHUB_OUTPUT - echo "::error::md-outputs branch required but does not exist." - echo "::error::Please merge any open package update PRs to trigger the '03 Maintain: Apply Package Cache' and '01: Maintain: Build and Deploy Site' workflows." - - echo "## ❌ ERROR: md-outputs branch required" >> $GITHUB_STEP_SUMMARY - echo "Please merge any open package update PRs to trigger the '03 Maintain: Apply Package Cache' and '01: Maintain: Build and Deploy Site' workflows." >> $GITHUB_STEP_SUMMARY - - exit 1 - fi - shell: bash - - test-pr: - name: "Record PR number" - if: | - github.event.action != 'closed' && - needs.preflight.outputs.branch-exists == 'true' - runs-on: ubuntu-latest - needs: preflight - outputs: - is_valid: ${{ steps.check-pr.outputs.VALID }} - pr_number: ${{ env.NR }} - pr_branch: ${{ env.PR_BRANCH }} - steps: - - name: "Grab PR" - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - if [[ "${{ github.event_name }}" == "pull_request" ]] ; then - PR_NUMBER=${{ github.event.number }} - elif [[ "${{ github.event_name }}" == "workflow_dispatch" ]] ; then - PR_NUMBER=${{ inputs.pr_number }} - fi - - echo $PR_NUMBER > ${{ github.workspace }}/NR - echo "NR=$PR_NUMBER" >> $GITHUB_ENV - echo "PR_BRANCH=$(gh -R ${{ github.repository }} pr view $PR_NUMBER --json headRefName --jq '.headRefName')" >> $GITHUB_ENV - shell: bash - - - name: "Upload PR number" - id: upload - if: always() - uses: actions/upload-artifact@v7 - with: - name: pr - path: ${{ github.workspace }}/NR - - - name: "Get Invalid Hashes File" - id: hash - run: | - echo "json<> $GITHUB_OUTPUT - shell: bash - - - name: "Debug Hashes Output" - run: | - echo "${{ steps.hash.outputs.json }}" - shell: bash - - - name: "Check PR" - id: check-pr - uses: carpentries/actions/check-valid-pr@main - with: - pr: ${{ env.NR }} - invalid: ${{ fromJSON(steps.hash.outputs.json)[github.repository] }} - - check-renv: - name: "Check If We Need {renv}" - runs-on: ubuntu-latest - outputs: - renv-needed: ${{ steps.renv-check.outputs.renv-needed }} - renv-cache-hashsum: ${{ steps.renv-check.outputs.renv-cache-hashsum }} - steps: - - name: "Checkout Lesson" - uses: actions/checkout@v6 - - - name: "Is renv required?" - id: renv-check - uses: carpentries/actions/renv-checks@main - with: - CACHE_VERSION: ${{ inputs.CACHE_VERSION || '' }} - skip-cache-check: true - - build-md-source: - name: "Build markdown source files if valid" - needs: - - test-pr - - check-renv - runs-on: ubuntu-latest - if: needs.test-pr.outputs.is_valid == 'true' - env: - CHIVE: ${{ github.workspace }}/site/chive - PR: ${{ github.workspace }}/site/pr - GHWMD: ${{ github.workspace }}/site/built - PR_BRANCH: ${{ needs.test-pr.outputs.pr_branch }} - PR_NUMBER: ${{ needs.test-pr.outputs.pr_number }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - permissions: - checks: write - contents: write - pages: write - id-token: write - container: - image: ghcr.io/carpentries/workbench-docker:${{ vars.WORKBENCH_TAG || 'latest' }} - env: - WORKBENCH_PROFILE: "ci" - GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RENV_PATHS_ROOT: /home/rstudio/lesson/renv - RENV_PROFILE: "lesson-requirements" - RENV_CONFIG_EXTERNAL_LIBRARIES: "/usr/local/lib/R/site-library" - volumes: - - ${{ github.workspace }}:/home/rstudio/lesson - options: --cpus 2 - outputs: - workbench-update: ${{ steps.wb-vers.outputs.workbench-update }} - build-site: ${{ steps.build-site.outcome }} - steps: - - uses: actions/checkout@v6 - - - name: "Check Out Staging Branch" - uses: actions/checkout@v6 - with: - ref: md-outputs - path: ${{ env.GHWMD }} - - - name: Mark Repository as Safe - run: | - git config --global --add safe.directory $(pwd) - git config --global --add safe.directory /home/rstudio/lesson - shell: bash - - - name: "Ensure sandpaper is loadable" - run: | - .libPaths() - library(sandpaper) - shell: Rscript {0} - - - name: Setup Lesson Dependencies - run: | - Rscript /home/rstudio/.workbench/setup_lesson_deps.R - shell: bash - - - name: Get Container Version Used - id: wb-vers - if: needs.check-renv.outputs.renv-needed == 'true' - uses: carpentries/actions/container-version@main - with: - WORKBENCH_TAG: ${{ vars.WORKBENCH_TAG }} - renv-needed: ${{ needs.check-renv.outputs.renv-needed }} - token: ${{ secrets.GITHUB_TOKEN }} - - - name: "Validate Current Org and Workflow" - id: validate-org-workflow - if: needs.check-renv.outputs.renv-needed == 'true' - uses: carpentries/actions/validate-org-workflow@main - with: - repo: ${{ github.repository }} - workflow: ${{ github.workflow }} - - - name: Configure AWS credentials via OIDC - id: aws-creds - env: - role-to-assume: ${{ secrets.AWS_GH_OIDC_ARN }} - aws-region: ${{ secrets.AWS_GH_OIDC_REGION }} - if: | - steps.validate-org-workflow.outputs.is_valid == 'true' && - needs.check-renv.outputs.renv-needed == 'true' && - env.role-to-assume != '' && - env.aws-region != '' - uses: aws-actions/configure-aws-credentials@v6 - with: - role-to-assume: ${{ env.role-to-assume }} - aws-region: ${{ env.aws-region }} - output-credentials: true - - - name: Get cache object from S3 - id: s3-cache - uses: tespkg/actions-cache/restore@v1.10.0 - if: needs.check-renv.outputs.renv-needed == 'true' - with: - # insecure: false # optional, use http instead of https. default false - accessKey: ${{ steps.aws-creds.outputs.aws-access-key-id }} - secretKey: ${{ steps.aws-creds.outputs.aws-secret-access-key }} - sessionToken: ${{ steps.aws-creds.outputs.aws-session-token }} - bucket: workbench-docker-caches - path: | - /home/rstudio/lesson/renv - /usr/local/lib/R/site-library - key: ${{ github.repository }}/${{ steps.wb-vers.outputs.container-version }}_renv-${{ needs.check-renv.outputs.renv-cache-hashsum }} - restore-keys: - ${{ github.repository }}/${{ steps.wb-vers.outputs.container-version }}_renv- - - - name: "Fortify renv Cache" - if: | - needs.check-renv.outputs.renv-needed == 'true' && - steps.s3-cache.outputs.cache-hit != 'true' - run: | - Rscript /home/rstudio/.workbench/fortify_renv_cache.R - shell: bash - - - name: "Validate and Build Markdown" - id: build-site - run: | - sandpaper::package_cache_trigger(TRUE) - sandpaper::validate_lesson(path = '/home/rstudio/lesson') - sandpaper:::build_markdown(path = '/home/rstudio/lesson', quiet = FALSE) - shell: Rscript {0} - - - name: "Generate Artifacts" - id: generate-artifacts - run: | - sandpaper:::ci_bundle_pr_artifacts( - repo = '${{ github.repository }}', - pr_number = '${{ env.PR_NUMBER }}', - path_md = '/home/rstudio/lesson/site/built', - path_pr = '/home/rstudio/lesson/site/pr', - path_archive = '/home/rstudio/lesson/site/chive', - branch = 'md-outputs' - ) - shell: Rscript {0} - - - name: "Upload PR" - uses: actions/upload-artifact@v7 - with: - name: pr - path: ${{ env.PR }} - overwrite: true - - - name: "Upload Diff" - uses: actions/upload-artifact@v7 - with: - name: diff - path: ${{ env.CHIVE }} - retention-days: 1 - - - name: "Upload Build" - uses: actions/upload-artifact@v7 - with: - name: built - path: ${{ env.GHWMD }} - retention-days: 1 - - - name: "Teardown" - run: sandpaper::reset_site() - shell: Rscript {0} diff --git a/.github/workflows/pr-close-signal.yaml b/.github/workflows/pr-close-signal.yaml deleted file mode 100644 index de1f25448..000000000 --- a/.github/workflows/pr-close-signal.yaml +++ /dev/null @@ -1,22 +0,0 @@ -name: "Bot: Send Close Pull Request Signal" - -on: - pull_request: - types: - [closed] - -jobs: - send-close-signal: - name: "Send closing signal" - runs-on: ubuntu-22.04 - if: ${{ github.event.action == 'closed' }} - steps: - - name: "Create PRtifact" - run: | - mkdir -p ./pr - printf ${{ github.event.number }} > ./pr/NUM - - name: Upload Diff - uses: actions/upload-artifact@v7 - with: - name: pr - path: ./pr diff --git a/.github/workflows/pr-comment.yaml b/.github/workflows/pr-comment.yaml deleted file mode 100644 index 9ec78c6c8..000000000 --- a/.github/workflows/pr-comment.yaml +++ /dev/null @@ -1,216 +0,0 @@ -name: "Bot: Comment on the Pull Request" -description: "Comment on the pull request with the results of the markdown generation" -on: - workflow_run: - workflows: ["Bot: Receive Pull Request"] - types: - - completed - -jobs: - # Pull requests are valid if: - # - they match the sha of the workflow run head commit - # - they are open - # - no .github files were committed, except for .github/workbench-docker-version.txt - test-pr: - name: "Test if pull request is valid" - runs-on: ubuntu-latest - outputs: - is_valid: ${{ steps.check-pr.outputs.VALID }} - payload: ${{ steps.check-pr.outputs.payload }} - number: ${{ steps.get-pr.outputs.NUM }} - msg: ${{ steps.check-pr.outputs.MSG }} - steps: - - name: "Download PR artifact" - id: dl - uses: carpentries/actions/download-workflow-artifact@main - with: - run: ${{ github.event.workflow_run.id }} - name: 'pr' - - - name: "Get PR Number" - if: ${{ steps.dl.outputs.success == 'true' }} - id: get-pr - run: | - unzip pr.zip - echo "NUM=$(<./NR)" >> $GITHUB_OUTPUT - - - name: "Fail if PR number was not present" - id: bad-pr - if: ${{ steps.dl.outputs.success != 'true' }} - run: | - echo '::error::A pull request number was not recorded. The pull request that triggered this workflow is likely malicious.' - exit 1 - - - name: "Checkout Lesson" - uses: actions/checkout@v6 - - - name: "Verify committed files" - id: changed-files - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - ## Get list of changed files in the PR ## - ONLY_VERSION=$(gh pr view ${{ steps.get-pr.outputs.NUM }} --json files --jq ' - .files | - length == 1 and - .[0].path == ".github/workbench-docker-version.txt" - ') - - if [[ "$ONLY_VERSION" == "true" ]]; then - echo "only_version_file=true" >> $GITHUB_OUTPUT - else - echo "only_version_file=false" >> $GITHUB_OUTPUT - fi - shell: bash - - - name: "Skip checks for Workbench version file updates" - if: steps.changed-files.outputs.only_version_file == 'true' - run: | - echo "# 🔧 Wait for Next Cache Update #" - echo "Only workbench-docker-version.txt changed." - exit 0 - shell: bash - - - name: "Get Invalid Hashes File" - id: hash - run: | - echo "json<> $GITHUB_OUTPUT - - - name: "Check PR" - id: check-pr - if: ${{ steps.dl.outputs.success == 'true' }} - uses: carpentries/actions/check-valid-pr@main - with: - pr: ${{ steps.get-pr.outputs.NUM }} - sha: ${{ github.event.workflow_run.head_sha }} - headroom: 3 # if it's within the last three commits, we can keep going, because it's likely rapid-fire - invalid: ${{ fromJSON(steps.hash.outputs.json)[github.repository] }} - fail_on_error: true - - - name: "Comment result of validation" - id: comment-diff - if: always() - uses: carpentries/actions/comment-diff@main - with: - pr: ${{ steps.get-pr.outputs.NUM }} - body: ${{ steps.check-pr.outputs.MSG }} - - # Create an orphan branch on this repository with two commits - # - the current HEAD of the md-outputs branch - # - the output from running the current HEAD of the pull request through - # the md generator - create-branch: - name: "Create Git Branch" - needs: test-pr - runs-on: ubuntu-latest - if: needs.test-pr.outputs.is_valid == 'true' - env: - NR: ${{ needs.test-pr.outputs.number }} - permissions: - contents: write - steps: - - name: "Checkout md outputs" - uses: actions/checkout@v6 - with: - ref: md-outputs - path: built - fetch-depth: 1 - - - name: "Download built markdown" - id: dl - uses: carpentries/actions/download-workflow-artifact@main - with: - run: ${{ github.event.workflow_run.id }} - name: 'built' - - - if: steps.dl.outputs.success == 'true' - run: unzip built.zip - - - name: "Create orphan and push" - if: steps.dl.outputs.success == 'true' - run: | - cd built/ - git config --local user.email "actions@github.com" - git config --local user.name "GitHub Actions" - CURR_HEAD=$(git rev-parse HEAD) - git checkout --orphan md-outputs-PR-${NR} - git add -A - git commit -m "source commit: ${CURR_HEAD}" - ls -A | grep -v '^.git$' | xargs -I _ rm -r '_' - cd .. - unzip -o -d built built.zip - cd built - git add -A - git commit --allow-empty -m "differences for PR #${NR}" - git push -u --force --set-upstream origin md-outputs-PR-${NR} - - # Comment on the Pull Request with a link to the branch and the diff - comment-pr: - name: "Comment on Pull Request" - needs: [test-pr, create-branch] - runs-on: ubuntu-latest - if: needs.test-pr.outputs.is_valid == 'true' - env: - NR: ${{ needs.test-pr.outputs.number }} - permissions: - pull-requests: write - steps: - - name: "Download comment artifact" - id: dl - uses: carpentries/actions/download-workflow-artifact@main - with: - run: ${{ github.event.workflow_run.id }} - name: 'diff' - - - if: steps.dl.outputs.success == 'true' - run: unzip ${{ github.workspace }}/diff.zip - - - name: "Comment on PR" - id: comment-diff - if: steps.dl.outputs.success == 'true' - uses: carpentries/actions/comment-diff@main - with: - pr: ${{ env.NR }} - path: ${{ github.workspace }}/diff.md - - # Comment if the PR is open and matches the SHA, but the workflow files have - # changed - comment-changed-workflow: - name: "Comment if workflow files have changed" - needs: test-pr - runs-on: ubuntu-latest - if: | - always() && - needs.test-pr.outputs.is_valid == 'false' - env: - NR: ${{ needs.test-pr.outputs.number }} - body: ${{ needs.test-pr.outputs.msg }} - permissions: - pull-requests: write - steps: - - name: "Check for spoofing" - id: dl - uses: carpentries/actions/download-workflow-artifact@main - with: - run: ${{ github.event.workflow_run.id }} - name: 'built' - - - name: "Alert if spoofed" - id: spoof - if: steps.dl.outputs.success == 'true' - run: | - echo 'body<> $GITHUB_ENV - echo '' >> $GITHUB_ENV - echo '## :x: DANGER :x:' >> $GITHUB_ENV - echo 'This pull request has modified workflows that created output. Close this now.' >> $GITHUB_ENV - echo '' >> $GITHUB_ENV - echo 'EOF' >> $GITHUB_ENV - - - name: "Comment on PR" - id: comment-diff - uses: carpentries/actions/comment-diff@main - with: - pr: ${{ env.NR }} - body: ${{ env.body }} diff --git a/.github/workflows/pr-post-remove-branch.yaml b/.github/workflows/pr-post-remove-branch.yaml deleted file mode 100644 index 9419e2be7..000000000 --- a/.github/workflows/pr-post-remove-branch.yaml +++ /dev/null @@ -1,32 +0,0 @@ -name: "Bot: Remove Temporary PR Branch" - -on: - workflow_run: - workflows: ["Bot: Send Close Pull Request Signal"] - types: - - completed - -jobs: - delete: - name: "Delete branch from Pull Request" - runs-on: ubuntu-22.04 - if: > - github.event.workflow_run.event == 'pull_request' && - github.event.workflow_run.conclusion == 'success' - permissions: - contents: write - steps: - - name: 'Download artifact' - uses: carpentries/actions/download-workflow-artifact@main - with: - run: ${{ github.event.workflow_run.id }} - name: pr - - name: "Get PR Number" - id: get-pr - run: | - unzip pr.zip - echo "NUM=$(<./NUM)" >> $GITHUB_OUTPUT - - name: 'Remove branch' - uses: carpentries/actions/remove-branch@main - with: - pr: ${{ steps.get-pr.outputs.NUM }} diff --git a/.github/workflows/pr-preflight.yaml b/.github/workflows/pr-preflight.yaml deleted file mode 100644 index d0d7420dc..000000000 --- a/.github/workflows/pr-preflight.yaml +++ /dev/null @@ -1,39 +0,0 @@ -name: "Pull Request Preflight Check" - -on: - pull_request_target: - branches: - ["main"] - types: - ["opened", "synchronize", "reopened"] - -jobs: - test-pr: - name: "Test if pull request is valid" - if: ${{ github.event.action != 'closed' }} - runs-on: ubuntu-latest - outputs: - is_valid: ${{ steps.check-pr.outputs.VALID }} - permissions: - pull-requests: write - steps: - - name: "Get Invalid Hashes File" - id: hash - run: | - echo "json<> $GITHUB_OUTPUT - - name: "Check PR" - id: check-pr - uses: carpentries/actions/check-valid-pr@main - with: - pr: ${{ github.event.number }} - invalid: ${{ fromJSON(steps.hash.outputs.json)[github.repository] }} - fail_on_error: true - - name: "Comment result of validation" - id: comment-diff - if: ${{ always() }} - uses: carpentries/actions/comment-diff@main - with: - pr: ${{ github.event.number }} - body: ${{ steps.check-pr.outputs.MSG }} diff --git a/.github/workflows/update-cache.yaml b/.github/workflows/update-cache.yaml deleted file mode 100644 index d182ac7a0..000000000 --- a/.github/workflows/update-cache.yaml +++ /dev/null @@ -1,190 +0,0 @@ -name: "02 Maintain: Check for Updated Packages" -description: "Check for updated R packages and create a pull request to update the lesson's renv lockfile and package cache" -on: - schedule: - - cron: '0 0 * * 2' - workflow_dispatch: - inputs: - name: - description: 'Who triggered this build?' - required: true - default: 'Maintainer (via GitHub)' - force-renv-init: - description: 'Force full lockfile update?' - required: false - default: false - type: boolean - update-packages: - description: 'Install any package updates?' - required: false - default: true - type: boolean - generate-cache: - description: 'Generate separate package cache?' - required: false - default: false - type: boolean - -env: - LOCKFILE_CACHE_GEN: ${{ vars.LOCKFILE_CACHE_GEN || github.event.inputs.generate-cache || 'false' }} - FORCE_RENV_INIT: ${{ vars.FORCE_RENV_INIT || github.event.inputs.force-renv-init || 'false' }} - UPDATE_PACKAGES: ${{ vars.UPDATE_PACKAGES || github.event.inputs.update-packages || 'true' }} - -jobs: - preflight: - name: "Preflight: Manual or Scheduled Trigger?" - runs-on: ubuntu-latest - outputs: - ok: ${{ steps.check.outputs.ok }} - steps: - - id: check - run: | - if [[ "${{ github.event_name }}" == 'workflow_dispatch' ]]; then - echo "ok=true" >> $GITHUB_OUTPUT - echo "Running on request" - # using single brackets here to avoid 08 being interpreted as octal - # https://github.com/carpentries/sandpaper/issues/250 - elif [ `date +%d` -le 7 ]; then - # If the Tuesday lands in the first week of the month, run it - echo "ok=true" >> $GITHUB_OUTPUT - echo "Running on schedule" - else - echo "ok=false" >> $GITHUB_OUTPUT - echo "Not Running Today" - fi - shell: bash - - check-renv: - name: "Check If We Need {renv}" - runs-on: ubuntu-latest - needs: preflight - if: ${{ needs.preflight.outputs.ok == 'true' }} - outputs: - renv-needed: ${{ steps.renv-check.outputs.renv-needed }} - steps: - - name: "Checkout Lesson" - uses: actions/checkout@v6 - - - name: "Is renv required?" - id: renv-check - uses: carpentries/actions/renv-checks@main - with: - CACHE_VERSION: ${{ inputs.CACHE_VERSION || '' }} - skip-cache-check: true - - update_cache: - name: "Create Package Update Pull Request" - runs-on: ubuntu-22.04 - needs: check-renv - permissions: - contents: write - pull-requests: write - actions: write - issues: write - id-token: write - if: needs.check-renv.outputs.renv-needed == 'true' - env: - GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RENV_PATHS_ROOT: ~/.local/share/renv/ - steps: - - name: "Checkout Lesson" - uses: actions/checkout@v6 - - - name: "Set up R" - uses: r-lib/actions/setup-r@v2 - with: - use-public-rspm: true - install-r: false - - - name: "Update {renv} deps and determine if a PR is needed" - id: update - uses: carpentries/actions/update-lockfile@main - with: - update: ${{ env.UPDATE_PACKAGES }} - force-renv-init: ${{ env.FORCE_RENV_INIT }} - generate-cache: ${{ env.LOCKFILE_CACHE_GEN }} - cache-version: ${{ secrets.CACHE_VERSION }} - - - name: "Validate Current Org and Workflow" - id: validate-org-workflow - uses: carpentries/actions/validate-org-workflow@main - with: - repo: ${{ github.repository }} - workflow: ${{ github.workflow }} - - - name: "Configure AWS credentials via OIDC" - env: - role-to-assume: ${{ secrets.AWS_GH_OIDC_ARN }} - aws-region: ${{ secrets.AWS_GH_OIDC_REGION }} - if: | - steps.validate-org-workflow.outputs.is_valid == 'true' && - env.role-to-assume != '' && - env.aws-region != '' - uses: aws-actions/configure-aws-credentials@v6 - with: - role-to-assume: ${{ env.role-to-assume }} - aws-region: ${{ env.aws-region }} - - - name: "Set PAT from AWS Secrets Manager" - env: - role-to-assume: ${{ secrets.AWS_GH_OIDC_ARN }} - aws-region: ${{ secrets.AWS_GH_OIDC_REGION }} - if: | - steps.validate-org-workflow.outputs.is_valid == 'true' && - env.role-to-assume != '' && - env.aws-region != '' - id: set-pat - run: | - SECRET=$(aws secretsmanager get-secret-value \ - --secret-id carpentries-bot/github-pat \ - --query SecretString --output text) - PAT=$(echo "$SECRET" | jq -r .[]) - echo "::add-mask::$PAT" - echo "pat=$PAT" >> "$GITHUB_OUTPUT" - shell: bash - - # Create the PR with the following roles in order of preference: - # - Carpentries Bot classic PAT fetched from AWS (will only work in official Carpentries repos) - # - repo-scoped SANDPAPER_WORKFLOW classic PAT (will work in all scenarios) - # - default GITHUB_TOKEN (will work suitably, but workflows need to be triggered) - - name: "Create Pull Request" - id: cpr - if: | - steps.update.outputs.n > 0 - uses: carpentries/create-pull-request@main - with: - token: ${{ steps.set-pat.outputs.pat || secrets.SANDPAPER_WORKFLOW }} - delete-branch: true - branch: "update/packages" - commit-message: "[actions] update ${{ steps.update.outputs.n }} packages" - title: "Update ${{ steps.update.outputs.n }} packages" - body: | - :robot: This is an automated build - - This will update ${{ steps.update.outputs.n }} packages in your lesson with the following versions: - - ``` - ${{ steps.update.outputs.report }} - ``` - - :stopwatch: In a few minutes, a comment will appear that will show you how the output has changed based on these updates. - - If you want to inspect these changes locally, you can use the following code to check out a new branch: - - ```bash - git fetch origin update/packages - git checkout update/packages - ``` - - - Auto-generated by [create-pull-request][1] on ${{ steps.update.outputs.date }} - - [1]: https://github.com/carpentries/create-pull-request/tree/main - labels: "type: package cache" - draft: false - - - name: "Skip PR creation" - if: steps.update.outputs.n == 0 - run: | - echo "No updates needed, skipping PR creation" - shell: bash diff --git a/.github/workflows/update-workflows.yaml b/.github/workflows/update-workflows.yaml deleted file mode 100644 index 35106872c..000000000 --- a/.github/workflows/update-workflows.yaml +++ /dev/null @@ -1,116 +0,0 @@ -name: "04 Maintain: Update Workflow Files" -description: "Update workflow files from the carpentries/sandpaper repository" - -on: - schedule: - - cron: '0 0 * * 2' - workflow_dispatch: - inputs: - name: - description: 'Who triggered this build (enter github username to tag yourself)?' - required: true - default: 'weekly run' - version: - description: 'Workflows version number (e.g. 0.0.1), branch name (e.g. main), or "latest"' - required: false - default: 'latest' - clean: - description: 'Workflow files/file extensions to clean (no wildcards, enter "" for none)' - required: false - default: '.yaml' - -jobs: - update_workflow: - name: "Update Workflow" - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - id-token: write - steps: - - name: "Checkout Repository" - uses: actions/checkout@v6 - - - name: "Validate Current Org and Workflow" - id: validate-org-workflow - uses: carpentries/actions/validate-org-workflow@main - with: - repo: ${{ github.repository }} - workflow: ${{ github.workflow }} - - - name: Configure AWS credentials via OIDC - env: - role-to-assume: ${{ secrets.AWS_GH_OIDC_ARN }} - aws-region: ${{ secrets.AWS_GH_OIDC_REGION }} - if: | - steps.validate-org-workflow.outputs.is_valid == 'true' && - env.role-to-assume != '' && - env.aws-region != '' - uses: aws-actions/configure-aws-credentials@v6 - with: - role-to-assume: ${{ env.role-to-assume }} - aws-region: ${{ env.aws-region }} - - - name: Set PAT from AWS Secrets Manager - id: set-pat - env: - role-to-assume: ${{ secrets.AWS_GH_OIDC_ARN }} - aws-region: ${{ secrets.AWS_GH_OIDC_REGION }} - if: | - steps.validate-org-workflow.outputs.is_valid == 'true' && - env.role-to-assume != '' && - env.aws-region != '' - run: | - SECRET=$(aws secretsmanager get-secret-value \ - --secret-id carpentries-bot/github-pat \ - --query SecretString --output text) - PAT=$(echo "$SECRET" | jq -r .[]) - echo "::add-mask::$PAT" - echo "pat=$PAT" >> "$GITHUB_OUTPUT" - shell: bash - - - name: "Validate token" - id: validate-token - uses: carpentries/actions/check-valid-credentials@main - with: - token: ${{ steps.set-pat.outputs.pat || secrets.SANDPAPER_WORKFLOW }} - - - name: "No Token Found: Skipping Workflow Update" - if: ${{ steps.validate-token.outputs.wf == 'false' }} - run: | - echo "❗No valid SANDPAPER_WORKFLOW token or PAT from AWS found, cannot update workflows." - - echo "## ❌ Workflow Update Failed" >> $GITHUB_STEP_SUMMARY - echo "No valid SANDPAPER_WORKFLOW token or PAT from AWS found, cannot update workflows." >> $GITHUB_STEP_SUMMARY - shell: bash - - - name: Update Workflows - id: update - if: ${{ steps.validate-token.outputs.wf == 'true' }} - uses: carpentries/actions/update-workflows@main - with: - version: ${{ github.event.inputs.version || 'latest' }} - clean: ${{ github.event.inputs.clean || '.yaml' }} - - - name: Create Pull Request - id: cpr - if: | - steps.update.outputs.new && - steps.validate-token.outputs.wf == 'true' - uses: carpentries/create-pull-request@main - with: - token: ${{ steps.set-pat.outputs.pat || secrets.SANDPAPER_WORKFLOW }} - delete-branch: true - branch: "update/workflows" - commit-message: "[actions] update sandpaper workflow to version ${{ steps.update.outputs.new }}" - title: "Update Workflows to Version ${{ steps.update.outputs.new }}" - body: | - :robot: This is an automated build - - Update Workflows from sandpaper version ${{ steps.update.outputs.old }} -> ${{ steps.update.outputs.new }} - - - Auto-generated by [create-pull-request][1] on ${{ steps.update.outputs.date }} - - [1]: https://github.com/carpentries/create-pull-request/tree/main - labels: "type: workflows" - draft: false diff --git a/.github/workflows/workflows-version.txt b/.github/workflows/workflows-version.txt deleted file mode 100644 index 7dea76edb..000000000 --- a/.github/workflows/workflows-version.txt +++ /dev/null @@ -1 +0,0 @@ -1.0.1 diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 34293534a..000000000 --- a/.gitignore +++ /dev/null @@ -1,56 +0,0 @@ -# sandpaper files -episodes/*html -site/* -!site/README.md - -# History files -.Rhistory -.Rapp.history -# Session Data files -.RData -# User-specific files -.Ruserdata -# Example code in package build process -*-Ex.R -# Output files from R CMD build -/*.tar.gz -# Output files from R CMD check -/*.Rcheck/ -# RStudio files -.Rproj.user/ -# produced vignettes -vignettes/*.html -vignettes/*.pdf -# OAuth2 token, see https://github.com/hadley/httr/releases/tag/v0.3 -.httr-oauth -# knitr and R markdown default cache directories -*_cache/ -/cache/ -# Temporary files created by R markdown -*.utf8.md -*.knit.md -# R Environment Variables -.Renviron -# pkgdown site -docs/ -# translation temp files -po/*~ -# renv detritus -renv/sandbox/ -*.pyc -*~ -.DS_Store -.ipynb_checkpoints -.sass-cache -.jekyll-cache/ -.jekyll-metadata -__pycache__ -_site -.Rproj.user -.bundle/ -.vendor/ -vendor/ -.docker-vendor/ -Gemfile.lock -.*history -.vscode/* diff --git a/.Rhistory b/.nojekyll similarity index 100% rename from .Rhistory rename to .nojekyll diff --git a/.zenodo.json b/.zenodo.json deleted file mode 100644 index 47df82a33..000000000 --- a/.zenodo.json +++ /dev/null @@ -1,61 +0,0 @@ -{ - "contributors": [ - { - "type": "Editor", - "name": "Toby Hodges", - "orcid": "0000-0003-1766-456X" - }, - { - "type": "Editor", - "name": "Ulf Schiller", - "orcid": "0000-0001-8941-1284" - }, - { - "type": "Editor", - "name": "Robert Turner" - }, - { - "type": "Editor", - "name": "David Palmquist" - }, - { - "type": "Editor", - "name": "Kimberly Meechan", - "orcid": "0000-0003-4939-4170" - } - ], - "creators": [ - { - "name": "Ulf Schiller", - "orcid": "0000-0001-8941-1284" - }, - { - "name": "Robert Turner" - }, - { - "name": "Candace Makeda Moore" - }, - { - "name": "Marianne Corvellec", - "orcid": "0000-0002-1994-3581" - }, - { - "name": "Toby Hodges", - "orcid": "0000-0003-1766-456X" - }, - { - "name": "govekk" - }, - { - "name": " Ruobin Qi", - "orcid": "0000-0001-9072-9484" - }, - { - "name": "Marco Dalla Vecchia", - "orcid": "0000-0002-0192-8439" - } - ], - "license": { - "id": "CC-BY-4.0" - } -} diff --git a/01-introduction.html b/01-introduction.html new file mode 100644 index 000000000..4b855ba25 --- /dev/null +++ b/01-introduction.html @@ -0,0 +1,559 @@ + +Image Processing with Python: Introduction +
+
+ + + + + +
+
+

Introduction

+

Last updated on 2024-11-28 | + + Edit this page

+ + + +
+ +
+ + + +
+

Overview

+
+
+
+
+

Questions

+
  • What sort of scientific questions can we answer with image +processing / computer vision?
  • +
  • What are morphometric problems?
  • +
+
+
+
+
+
+

Objectives

+
  • Recognise scientific questions that could be solved with image +processing / computer vision.
  • +
  • Recognise morphometric problems (those dealing with the number, +size, or shape of the objects in an image).
  • +
+
+
+
+
+

As computer systems have become faster and more powerful, and cameras +and other imaging systems have become commonplace in many other areas of +life, the need has grown for researchers to be able to process and +analyse image data. Considering the large volumes of data that can be +involved - high-resolution images that take up a lot of disk +space/virtual memory, and/or collections of many images that must be +processed together - and the time-consuming and error-prone nature of +manual processing, it can be advantageous or even necessary for this +processing and analysis to be automated as a computer program.

+

This lesson introduces an open source toolkit for processing image +data: the Python programming language and the scikit-image +(skimage) library. With careful experimental design, +Python code can be a powerful instrument in answering many different +kinds of questions.

+

Uses of Image Processing in Research

+

Automated processing can be used to analyse many different properties +of an image, including the distribution and change in colours in the +image, the number, size, position, orientation, and shape of objects in +the image, and even - when combined with machine learning techniques for +object recognition - the type of objects in the image.

+

Some examples of image processing methods applied in research +include:

+

With this lesson, we aim to provide a thorough grounding in the +fundamental concepts and skills of working with image data in Python. +Most of the examples used in this lesson focus on one particular class +of image processing technique, morphometrics, but what you will +learn can be used to solve a much wider range of problems.

+

Morphometrics

+

Morphometrics involves counting the number of objects in an image, +analyzing the size of the objects, or analyzing the shape of the +objects. For example, we might be interested in automatically counting +the number of bacterial colonies growing in a Petri dish, as shown in +this image:

+
Bacteria colony

We could use image processing to find the colonies, count them, and +then highlight their locations on the original image, resulting in an +image like this:

+
Colonies counted
+
+ +
+Callout +
+

Why write a program to do that?

+
+

Note that you can easily manually count the number of bacteria +colonies shown in the morphometric example above. Why should we learn +how to write a Python program to do a task we could easily perform with +our own eyes? There are at least two reasons to learn how to perform +tasks like these with Python and scikit-image:

+
  1. What if there are many more bacteria colonies in the Petri dish? For +example, suppose the image looked like this:
  2. +
Bacteria colony

Manually counting the colonies in that image would present more of a +challenge. A Python program using scikit-image could count the number of +colonies more accurately, and much more quickly, than a human could.

+
  1. What if you have hundreds, or thousands, of images to consider? +Imagine having to manually count colonies on several thousand images +like those above. A Python program using scikit-image could move through +all of the images in seconds; how long would a graduate student require +to do the task? Which process would be more accurate and +repeatable?
  2. +

As you can see, the simple image processing / computer vision +techniques you will learn during this workshop can be very valuable +tools for scientific research.

+
+
+
+

As we move through this workshop, we will learn image analysis +methods useful for many different scientific problems. These will be +linked together and applied to a real problem in the final +end-of-workshop capstone challenge.

+

Let’s get started, by learning some basics about how images are +represented and stored digitally.

+
+
+ +
+Key Points +
+
+
  • Simple Python and scikit-image techniques can be used to solve +genuine image analysis problems.
  • +
  • Morphometric problems involve the number, shape, and / or size of +the objects in an image.
  • +
+
+
+
+
+ + +
+
+ + + diff --git a/02-image-basics.html b/02-image-basics.html new file mode 100644 index 000000000..722ab8a39 --- /dev/null +++ b/02-image-basics.html @@ -0,0 +1,1599 @@ + +Image Processing with Python: Image Basics +
+
+ + + + + +
+
+

Image Basics

+

Last updated on 2024-12-01 | + + Edit this page

+ + + +
+ +
+ + + +
+

Overview

+
+
+
+
+

Questions

+
  • How are images represented in digital format?
  • +
+
+
+
+
+
+

Objectives

+
  • Define the terms bit, byte, kilobyte, megabyte, etc.
  • +
  • Explain how a digital image is composed of pixels.
  • +
  • Recommend using imageio (resp. scikit-image) for I/O (resp. image +processing) tasks.
  • +
  • Explain how images are stored in NumPy arrays.
  • +
  • Explain the left-hand coordinate system used in digital images.
  • +
  • Explain the RGB additive colour model used in digital images.
  • +
  • Explain the order of the three colour values in scikit-image +images.
  • +
  • Explain the characteristics of the BMP, JPEG, and TIFF image +formats.
  • +
  • Explain the difference between lossy and lossless compression.
  • +
  • Explain the advantages and disadvantages of compressed image +formats.
  • +
  • Explain what information could be contained in image metadata.
  • +
+
+
+
+
+

The images we see on hard copy, view with our electronic devices, or +process with our programs are represented and stored in the computer as +numeric abstractions, approximations of what we see with our eyes in the +real world. Before we begin to learn how to process images with Python +programs, we need to spend some time understanding how these +abstractions work.

+
+
+ +
+Callout +
+
+

Feel free to make use of the available cheat-sheet as a guide for +the rest of the course material. View it online, share it, or print the +PDF!

+
+
+
+

Pixels

+

It is important to realise that images are stored as rectangular +arrays of hundreds, thousands, or millions of discrete “picture +elements,” otherwise known as pixels. Each pixel can be thought +of as a single square point of coloured light.

+

For example, consider this image of a maize seedling, with a square +area designated by a red box:

+
Original size image

Now, if we zoomed in close enough to see the pixels in the red box, +we would see something like this:

+
Enlarged image area

Note that each square in the enlarged image area - each pixel - is +all one colour, but that each pixel can have a different colour from its +neighbors. Viewed from a distance, these pixels seem to blend together +to form the image we see.

+

Real-world images are typically made up of a vast number of pixels, +and each of these pixels is one of potentially millions of colours. +While we will deal with pictures of such complexity in this lesson, +let’s start our exploration with just 15 pixels in a 5 x 3 matrix with 2 +colours, and work our way up to that complexity.

+
+
+ +
+Callout +
+

Matrices, arrays, images and pixels

+
+

A matrix is a mathematical concept - numbers evenly +arranged in a rectangle. This can be a two-dimensional rectangle, like +the shape of the screen you’re looking at now. Or it could be a +three-dimensional equivalent, a cuboid, or have even more dimensions, +but always keeping the evenly spaced arrangement of numbers. In +computing, an array refers to a structure in the +computer’s memory where data is stored in evenly spaced +elements. This is strongly analogous to a matrix. A +NumPy array is a type of variable (a simpler example of +a type is an integer). For our purposes, the distinction between +matrices and arrays is not important, we don’t really care how the +computer arranges our data in its memory. The important thing is that +the computer stores values describing the pixels in images, as arrays. +And the terms matrix and array will be used interchangeably.

+
+
+
+

Loading images

+

As noted, images we want to analyze (process) with Python are loaded +into arrays. There are multiple ways to load images. In this lesson, we +use imageio, a Python library for reading (loading) and writing (saving) +image data, and more specifically its version 3. But, really, we could +use any image loader which would return a NumPy array.

+
+

PYTHON +

+
"""Python library for reading and writing images."""
+
+import imageio.v3 as iio
+
+

The v3 module of imageio (imageio.v3) is +imported as iio (see note in the next section). Version 3 +of imageio has the benefit of supporting nD (multidimensional) image +data natively (think of volumes, movies).

+

Let us load our image data from disk using the imread +function from the imageio.v3 module.

+
+

PYTHON +

+
eight = iio.imread(uri="data/eight.tif")
+print(type(eight))
+
+
+

OUTPUT +

+
<class 'numpy.ndarray'>
+
+

Note that, using the same image loader or a different one, we could +also read in remotely hosted data.

+
+
+ +
+Callout +
+

Why not use +skimage.io.imread()?

+
+

The scikit-image library has its own function to read an image, so +you might be asking why we don’t use it here. Actually, +skimage.io.imread() uses iio.imread() +internally when loading an image into Python. It is certainly something +you may use as you see fit in your own code. In this lesson, we use the +imageio library to read or write images, while scikit-image is dedicated +to performing operations on the images. Using imageio gives us more +flexibility, especially when it comes to handling metadata.

+
+
+
+
+
+ +
+Callout +
+

Beyond NumPy arrays

+
+

Beyond NumPy arrays, there exist other types of variables which are +array-like. Notably, pandas.DataFrame +and xarray.DataArray +can hold labeled, tabular data. These are not natively supported in +scikit-image, the scientific toolkit we use in this lesson for +processing image data. However, data stored in these types can be +converted to numpy.ndarray with certain assumptions (see +pandas.DataFrame.to_numpy() and +xarray.DataArray.data). Particularly, these conversions +ignore the sampling coordinates (DataFrame.index, +DataFrame.columns, or DataArray.coords), which +may result in misrepresented data, for instance, when the original data +points are irregularly spaced.

+
+
+
+

Working with pixels

+

First, let us add the necessary imports:

+
+

PYTHON +

+
"""Python libraries for learning and performing image processing."""
+
+import ipympl
+import matplotlib.pyplot as plt
+import numpy as np
+import skimage as ski
+
+
+
+ +
+Callout +
+

Import statements in Python

+
+

In Python, the import statement is used to load +additional functionality into a program. This is necessary when we want +our code to do something more specialised, which cannot easily be +achieved with the limited set of basic tools and data structures +available in the default Python environment.

+

Additional functionality can be loaded as a single function or +object, a module defining several of these, or a library containing many +modules. You will encounter several different forms of +import statement.

+
+

PYTHON +

+
import skimage                 # form 1, load whole skimage library
+import skimage.draw            # form 2, load skimage.draw module only
+from skimage.draw import disk  # form 3, load only the disk function
+import skimage as ski          # form 4, load all of skimage into an object called ski
+
+
+
+ +
+
+

In the example above, form 1 loads the entire scikit-image library +into the program as an object. Individual modules of the library are +then available within that object, e.g., to access the disk +function used in the drawing episode, you +would write skimage.draw.disk().

+

Form 2 loads only the draw module of +skimage into the program. The syntax needed to use the +module remains unchanged: to access the disk function, we +would use the same function call as given for form 1.

+

Form 3 can be used to import only a specific function/class from a +library/module. Unlike the other forms, when this approach is used, the +imported function or class can be called by its name only, without +prefixing it with the name of the library/module from which it was +loaded, i.e., disk() instead of +skimage.draw.disk() using the example above. One hazard of +this form is that importing like this will overwrite any object with the +same name that was defined/imported earlier in the program, i.e., the +example above would replace any existing object called disk +with the disk function from skimage.draw.

+

Finally, the as keyword can be used when importing, to +define a name to be used as shorthand for the library/module being +imported. This name is referred to as an alias. Typically, using an +alias (such as np for the NumPy library) saves us a little +typing. You may see as combined with any of the other first +three forms of import statements.

+

Which form is used often depends on the size and number of additional +tools being loaded into the program.

+
+
+
+
+
+
+
+

Now that we have our libraries loaded, we will run a Jupyter Magic +Command that will ensure our images display in our Jupyter document with +pixel information that will help us more efficiently run commands later +in the session.

+
+

PYTHON +

+
%matplotlib widget
+
+

With that taken care of, let us display the image we have loaded, +using the imshow function from the +matplotlib.pyplot module.

+
+

PYTHON +

+
fig, ax = plt.subplots()
+ax.imshow(eight)
+
+
Image of 8

You might be thinking, “That does look vaguely like an eight, and I +see two colours but how can that be only 15 pixels”. The display of the +eight you see does use a lot more screen pixels to display our eight so +large, but that does not mean there is information for all those screen +pixels in the file. All those extra pixels are a consequence of our +viewer creating additional pixels through interpolation. It could have +just displayed it as a tiny image using only 15 screen pixels if the +viewer was designed differently.

+

While many image file formats contain descriptive metadata that can +be essential, the bulk of a picture file is just arrays of numeric +information that, when interpreted according to a certain rule set, +become recognizable as an image to us. Our image of an eight is no +exception, and imageio.v3 stored that image data in an +array of arrays making a 5 x 3 matrix of 15 pixels. We can demonstrate +that by calling on the shape property of our image variable and see the +matrix by printing our image variable to the screen.

+
+

PYTHON +

+
print(eight.shape)
+print(eight)
+
+
+

OUTPUT +

+
(5, 3)
+[[0. 0. 0.]
+ [0. 1. 0.]
+ [0. 0. 0.]
+ [0. 1. 0.]
+ [0. 0. 0.]]
+
+

Thus if we have tools that will allow us to manipulate these arrays +of numbers, we can manipulate the image. The NumPy library can be +particularly useful here, so let’s try that out using NumPy array +slicing. Notice that the default behavior of the imshow +function appended row and column numbers that will be helpful to us as +we try to address individual or groups of pixels. First let’s load +another copy of our eight, and then make it look like a zero.

+

To make it look like a zero, we need to change the number underlying +the centremost pixel to be 1. With the help of those row and column +headers, at this small scale we can determine the centre pixel is in row +labeled 2 and column labeled 1. Using array slicing, we can then address +and assign a new value to that position.

+
+

PYTHON +

+
zero = iio.imread(uri="data/eight.tif")
+zero[2, 1]= 1.0
+
+# The following line of code creates a new figure for imshow to use in displaying our output.
+fig, ax = plt.subplots()
+ax.imshow(zero)
+print(zero)
+
+
+

OUTPUT +

+
[[0. 0. 0.]
+ [0. 1. 0.]
+ [0. 1. 0.]
+ [0. 1. 0.]
+ [0. 0. 0.]]
+
+
Image of 0
+
+ +
+Callout +
+

Coordinate system

+
+

When we process images, we can access, examine, and / or change the +colour of any pixel we wish. To do this, we need some convention on how +to access pixels individually; a way to give each one a name, or an +address of a sort.

+

The most common manner to do this, and the one we will use in our +programs, is to assign a modified Cartesian coordinate system to the +image. The coordinate system we usually see in mathematics has a +horizontal x-axis and a vertical y-axis, like this:

+
Cartesian coordinate system

The modified coordinate system used for our images will have only +positive coordinates, the origin will be in the upper left corner +instead of the centre, and y coordinate values will get larger as they +go down instead of up, like this:

+
Image coordinate system

This is called a left-hand coordinate system. If you hold +your left hand in front of your face and point your thumb at the floor, +your extended index finger will correspond to the x-axis while your +thumb represents the y-axis.

+
Left-hand coordinate system

Until you have worked with images for a while, the most common +mistake that you will make with coordinates is to forget that y +coordinates get larger as they go down instead of up as in a normal +Cartesian coordinate system. Consequently, it may be helpful to think in +terms of counting down rows (r) for the y-axis and across columns (c) +for the x-axis. This can be especially helpful in cases where you need +to transpose image viewer data provided in x,y format to +y,x format. Thus, we will use cx and ry where +appropriate to help bridge these two approaches.

+
+
+
+
+
+ +
+Challenge +
+

Changing Pixel Values (5 min)

+
+

Load another copy of eight named five, and then change the value of +pixels so you have what looks like a 5 instead of an 8. Display the +image and print out the matrix as well.

+
+
+
+
+
+ +
+
+

There are many possible solutions, but one method would be . . .

+
+

PYTHON +

+
five = iio.imread(uri="data/eight.tif")
+five[1, 2] = 1.0
+five[3, 0] = 1.0
+fig, ax = plt.subplots()
+ax.imshow(five)
+print(five)
+
+
+

OUTPUT +

+
[[0. 0. 0.]
+ [0. 1. 1.]
+ [0. 0. 0.]
+ [1. 1. 0.]
+ [0. 0. 0.]]
+
+
Image of 5
+
+
+
+

More colours

+

Up to now, we only had a 2 colour matrix, but we can have more if we +use other numbers or fractions. One common way is to use the numbers +between 0 and 255 to allow for 256 different colours or 256 different +levels of grey. Let’s try that out.

+
+

PYTHON +

+
# make a copy of eight
+three_colours = iio.imread(uri="data/eight.tif")
+
+# multiply the whole matrix by 128
+three_colours = three_colours * 128
+
+# set the middle row (index 2) to the value of 255.,
+# so you end up with the values 0., 128., and 255.
+three_colours[2, :] = 255.
+fig, ax = plt.subplots()
+ax.imshow(three_colours)
+print(three_colours)
+
+
Image of three colours

We now have 3 colours, but are they the three colours you expected? +They all appear to be on a continuum of dark purple on the low end and +yellow on the high end. This is a consequence of the default colour map +(cmap) in this library. You can think of a colour map as an association +or mapping of numbers to a specific colour. However, the goal here is +not to have one number for every possible colour, but rather to have a +continuum of colours that demonstrate relative intensity. In our +specific case here for example, 255 or the highest intensity is mapped +to yellow, and 0 or the lowest intensity is mapped to a dark purple. The +best colour map for your data will vary and there are many options built +in, but this default selection was not arbitrary. A lot of science went +into making this the default due to its robustness when it comes to how +the human mind interprets relative colour values, grey-scale +printability, and colour-blind friendliness (You can read more about +this default colour map in a +Matplotlib tutorial and an explanatory article by the +authors). Thus it is a good place to start, and you should change it +only with purpose and forethought. For now, let’s see how you can do +that using an alternative map you have likely seen before where it will +be even easier to see it as a mapped continuum of intensities: +greyscale.

+
+

PYTHON +

+
fig, ax = plt.subplots()
+ax.imshow(three_colours, cmap="gray")
+
+
Image in greyscale

Above we have exactly the same underlying data matrix, but in +greyscale. Zero maps to black, 255 maps to white, and 128 maps to medium +grey. Here we only have a single channel in the data and utilize a +grayscale color map to represent the luminance, or intensity of the data +and correspondingly this channel is referred to as the luminance +channel.

+

Even more colours

+

This is all well and good at this scale, but what happens when we +instead have a picture of a natural landscape that contains millions of +colours. Having a one to one mapping of number to colour like this would +be inefficient and make adjustments and building tools to do so very +difficult. Rather than larger numbers, the solution is to have more +numbers in more dimensions. Storing the numbers in a multi-dimensional +matrix where each colour or property like transparency is associated +with its own dimension allows for individual contributions to a pixel to +be adjusted independently. This ability to manipulate properties of +groups of pixels separately will be key to certain techniques explored +in later chapters of this lesson. To get started let’s see an example of +how different dimensions of information combine to produce a set of +pixels using a 4 x 4 matrix with 3 dimensions for the colours red, +green, and blue. Rather than loading it from a file, we will generate +this example using NumPy.

+
+

PYTHON +

+
# set the random seed so we all get the same matrix
+pseudorandomizer = np.random.RandomState(2021)
+# create a 4 × 4 checkerboard of random colours
+checkerboard = pseudorandomizer.randint(0, 255, size=(4, 4, 3))
+# restore the default map as you show the image
+fig, ax = plt.subplots()
+ax.imshow(checkerboard)
+# display the arrays
+print(checkerboard)
+
+
+

OUTPUT +

+
[[[116  85  57]
+  [128 109  94]
+  [214  44  62]
+  [219 157  21]]
+
+ [[ 93 152 140]
+  [246 198 102]
+  [ 70  33 101]
+  [  7   1 110]]
+
+ [[225 124 229]
+  [154 194 176]
+  [227  63  49]
+  [144 178  54]]
+
+ [[123 180  93]
+  [120   5  49]
+  [166 234 142]
+  [ 71  85  70]]]
+
+
Image of checkerboard

Previously we had one number being mapped to one colour or intensity. +Now we are combining the effect of 3 numbers to arrive at a single +colour value. Let’s see an example of that using the blue square at the +end of the second row, which has the index [1, 3].

+
+

PYTHON +

+
# extract all the colour information for the blue square
+upper_right_square = checkerboard[1, 3, :]
+upper_right_square
+
+

This outputs: array([ 7, 1, 110]) The integers in order represent +Red, Green, and Blue. Looking at the 3 values and knowing how they map, +can help us understand why it is blue. If we divide each value by 255, +which is the maximum, we can determine how much it is contributing +relative to its maximum potential. Effectively, the red is at 7/255 or +2.8 percent of its potential, the green is at 1/255 or 0.4 percent, and +blue is 110/255 or 43.1 percent of its potential. So when you mix those +three intensities of colour, blue is winning by a wide margin, but the +red and green still contribute to make it a slightly different shade of +blue than 0,0,110 would be on its own.

+

These colours mapped to dimensions of the matrix may be referred to +as channels. It may be helpful to display each of these channels +independently, to help us understand what is happening. We can do that +by multiplying our image array representation with a 1d matrix that has +a one for the channel we want to keep and zeros for the rest.

+
+

PYTHON +

+
red_channel = checkerboard * [1, 0, 0]
+fig, ax = plt.subplots()
+ax.imshow(red_channel)
+
+
Image of red channel
+

PYTHON +

+
green_channel = checkerboard * [0, 1, 0]
+fig, ax = plt.subplots()
+ax.imshow(green_channel)
+
+
Image of green channel
+

PYTHON +

+
blue_channel = checkerboard * [0, 0, 1]
+fig, ax = plt.subplots()
+ax.imshow(blue_channel)
+
+
Image of blue channel

If we look at the upper [1, 3] square in all three figures, we can +see each of those colour contributions in action. Notice that there are +several squares in the blue figure that look even more intensely blue +than square [1, 3]. When all three channels are combined though, the +blue light of those squares is being diluted by the relative strength of +red and green being mixed in with them.

+

24-bit RGB colour

+

This last colour model we used, known as the RGB (Red, Green, +Blue) model, is the most common.

+

As we saw, the RGB model is an additive colour model, which +means that the primary colours are mixed together to form other colours. +Most frequently, the amount of the primary colour added is represented +as an integer in the closed range [0, 255] as seen in the example. +Therefore, there are 256 discrete amounts of each primary colour that +can be added to produce another colour. The number of discrete amounts +of each colour, 256, corresponds to the number of bits used to hold the +colour channel value, which is eight (28=256). Since we have +three channels with 8 bits for each (8+8+8=24), this is called 24-bit +colour depth.

+

Any particular colour in the RGB model can be expressed by a triplet +of integers in [0, 255], representing the red, green, and blue channels, +respectively. A larger number in a channel means that more of that +primary colour is present.

+
+
+ +
+Challenge +
+

Thinking about RGB colours (5 min)

+
+

Suppose that we represent colours as triples (r, g, b), where each of +r, g, and b is an integer in [0, 255]. What colours are represented by +each of these triples? (Try to answer these questions without reading +further.)

+
  1. (255, 0, 0)
  2. +
  3. (0, 255, 0)
  4. +
  5. (0, 0, 255)
  6. +
  7. (255, 255, 255)
  8. +
  9. (0, 0, 0)
  10. +
  11. (128, 128, 128)
  12. +
+
+
+
+
+ +
+
+
  1. (255, 0, 0) represents red, because the red channel is maximised, +while the other two channels have the minimum values.
  2. +
  3. (0, 255, 0) represents green.
  4. +
  5. (0, 0, 255) represents blue.
  6. +
  7. (255, 255, 255) is a little harder. When we mix the maximum value of +all three colour channels, we see the colour white.
  8. +
  9. (0, 0, 0) represents the absence of all colour, or black.
  10. +
  11. (128, 128, 128) represents a medium shade of gray. Note that the +24-bit RGB colour model provides at least 254 shades of gray, rather +than only fifty.
  12. +

Note that the RGB colour model may run contrary to your experience, +especially if you have mixed primary colours of paint to create new +colours. In the RGB model, the lack of any colour is black, +while the maximum amount of each of the primary colours is +white. With physical paint, we might start with a white base, and then +add differing amounts of other paints to produce a darker shade.

+
+
+
+
+

After completing the previous challenge, we can look at some further +examples of 24-bit RGB colours, in a visual way. The image in the next +challenge shows some colour names, their 24-bit RGB triplet values, and +the colour itself.

+
+
+ +
+Challenge +
+

RGB colour table (optional, not included in +timing)

+
+
RGB colour table

We cannot really provide a complete table. To see why, answer this +question: How many possible colours can be represented with the 24-bit +RGB model?

+
+
+
+
+
+ +
+
+

There are 24 total bits in an RGB colour of this type, and each bit +can be on or off, and so there are 224 = 16,777,216 possible +colours with our additive, 24-bit RGB colour model.

+
+
+
+
+

Although 24-bit colour depth is common, there are other options. For +example, we might have 8-bit colour (3 bits for red and green, but only +2 for blue, providing 8 × 8 × 4 = 256 colours) or 16-bit colour (4 bits +for red, green, and blue, plus 4 more for transparency, providing 16 × +16 × 16 = 4096 colours, with 16 transparency levels each). There are +colour depths with more than eight bits per channel, but as the human +eye can only discern approximately 10 million different colours, these +are not often used.

+

If you are using an older or inexpensive laptop screen or LCD monitor +to view images, it may only support 18-bit colour, capable of displaying +64 × 64 × 64 = 262,144 colours. 24-bit colour images will be converted +in some manner to 18-bit, and thus the colour quality you see will not +match what is actually in the image.

+

We can combine our coordinate system with the 24-bit RGB colour model +to gain a conceptual understanding of the images we will be working +with. An image is a rectangular array of pixels, each with its own +coordinate. Each pixel in the image is a square point of coloured light, +where the colour is specified by a 24-bit RGB triplet. Such an image is +an example of raster graphics.

+

Image formats

+

Although the images we will manipulate in our programs are +conceptualised as rectangular arrays of RGB triplets, they are not +necessarily created, stored, or transmitted in that format. There are +several image formats we might encounter, and we should know the basics +of at least of few of them. Some formats we might encounter, and their +file extensions, are shown in this table:

+ + + + + + + + +
FormatExtension
Device-Independent Bitmap (BMP).bmp
Joint Photographic Experts Group (JPEG).jpg or .jpeg
Tagged Image File Format (TIFF).tif or .tiff

BMP

+

The file format that comes closest to our preceding conceptualisation +of images is the Device-Independent Bitmap, or BMP, file format. BMP +files store raster graphics images as long sequences of binary-encoded +numbers that specify the colour of each pixel in the image. Since +computer files are one-dimensional structures, the pixel colours are +stored one row at a time. That is, the first row of pixels (those with +y-coordinate 0) are stored first, followed by the second row (those with +y-coordinate 1), and so on. Depending on how it was created, a BMP image +might have 8-bit, 16-bit, or 24-bit colour depth.

+

24-bit BMP images have a relatively simple file format, can be viewed +and loaded across a wide variety of operating systems, and have high +quality. However, BMP images are not compressed, resulting in +very large file sizes for any useful image resolutions.

+

The idea of image compression is important to us for two reasons: +first, compressed images have smaller file sizes, and are therefore +easier to store and transmit; and second, compressed images may not have +as much detail as their uncompressed counterparts, and so our programs +may not be able to detect some important aspect if we are working with +compressed images. Since compression is important to us, we should take +a brief detour and discuss the concept.

+

Image compression

+

Before discussing additional formats, familiarity with image +compression will be helpful. Let’s delve into that subject with a +challenge. For this challenge, you will need to know about bits / bytes +and how those are used to express computer storage capacities. If you +already know, you can skip to the challenge below.

+
+
+ +
+Callout +
+

Bits and bytes

+
+

Before we talk specifically about images, we first need to understand +how numbers are stored in a modern digital computer. When we think of a +number, we do so using a decimal, or base-10 +place-value number system. For example, a number like 659 is 6 × +102 + 5 × 101 + 9 × 100. Each digit in +the number is multiplied by a power of 10, based on where it occurs, and +there are 10 digits that can occur in each position (0, 1, 2, 3, 4, 5, +6, 7, 8, 9).

+

In principle, computers could be constructed to represent numbers in +exactly the same way. But, the electronic circuits inside a computer are +much easier to construct if we restrict the numeric base to only two, +instead of 10. (It is easier for circuitry to tell the difference +between two voltage levels than it is to differentiate among 10 levels.) +So, values in a computer are stored using a binary, or +base-2 place-value number system.

+

In this system, each symbol in a number is called a bit +instead of a digit, and there are only two values for each bit (0 and +1). We might imagine a four-bit binary number, 1101. Using the same kind +of place-value expansion as we did above for 659, we see that 1101 = 1 × +23 + 1 × 22 + 0 × 21 + 1 × +20, which if we do the math is 8 + 4 + 0 + 1, or 13 in +decimal.

+

Internally, computers have a minimum number of bits that they work +with at a given time: eight. A group of eight bits is called a +byte. The amount of memory (RAM) and drive space our computers +have is quantified by terms like Megabytes (MB), Gigabytes (GB), and +Terabytes (TB). The following table provides more formal definitions for +these terms.

+ + + + + + + + + + + + + + + +
UnitAbbreviationSize
KilobyteKB1024 bytes
MegabyteMB1024 KB
GigabyteGB1024 MB
TerabyteTB1024 GB
+
+
+
+
+ +
+Challenge +
+

BMP image size (optional, not included in +timing)

+
+

Imagine that we have a fairly large, but very boring image: a 5,000 × +5,000 pixel image composed of nothing but white pixels. If we used an +uncompressed image format such as BMP, with the 24-bit RGB colour model, +how much storage would be required for the file?

+
+
+
+
+
+ +
+
+

In such an image, there are 5,000 × 5,000 = 25,000,000 pixels, and 24 +bits for each pixel, leading to 25,000,000 × 24 = 600,000,000 bits, or +75,000,000 bytes (71.5MB). That is quite a lot of space for a very +uninteresting image!

+
+
+
+
+

Since image files can be very large, various compression +schemes exist for saving (approximately) the same information while +using less space. These compression techniques can be categorised as +lossless or lossy.

+
+

Lossless compression

+

In lossless image compression, we apply some algorithm (i.e., a +computerised procedure) to the image, resulting in a file that is +significantly smaller than the uncompressed BMP file equivalent would +be. Then, when we wish to load and view or process the image, our +program reads the compressed file, and reverses the compression process, +resulting in an image that is identical to the original. +Nothing is lost in the process – hence the term “lossless.”

+

The general idea of lossless compression is to somehow detect long +patterns of bytes in a file that are repeated over and over, and then +assign a smaller bit pattern to represent the longer sample. Then, the +compressed file is made up of the smaller patterns, rather than the +larger ones, thus reducing the number of bytes required to save the +file. The compressed file also contains a table of the substituted +patterns and the originals, so when the file is decompressed it can be +made identical to the original before compression.

+

To provide you with a concrete example, consider the 71.5 MB white +BMP image discussed above. When put through the zip compression utility +on Microsoft Windows, the resulting .zip file is only 72 KB in size! +That is, the .zip version of the image is three orders of magnitude +smaller than the original, and it can be decompressed into a file that +is byte-for-byte the same as the original. Since the original is so +repetitious - simply the same colour triplet repeated 25,000,000 times - +the compression algorithm can dramatically reduce the size of the +file.

+

If you work with .zip or .gz archives, you are dealing with lossless +compression.

+
+
+

Lossy compression

+

Lossy compression takes the original image and discards some of the +detail in it, resulting in a smaller file format. The goal is to only +throw away detail that someone viewing the image would not notice. Many +lossy compression schemes have adjustable levels of compression, so that +the image creator can choose the amount of detail that is lost. The more +detail that is sacrificed, the smaller the image files will be - but of +course, the detail and richness of the image will be lower as well.

+

This is probably fine for images that are shown on Web pages or +printed off on 4 × 6 photo paper, but may or may not be fine for +scientific work. You will have to decide whether the loss of image +quality and detail are important to your work, versus the space savings +afforded by a lossy compression format.

+

It is important to understand that once an image is saved in a lossy +compression format, the lost detail is just that - lost. I.e., unlike +lossless formats, given an image saved in a lossy format, there is no +way to reconstruct the original image in a byte-by-byte manner.

+
+

JPEG

+

JPEG images are perhaps the most commonly encountered digital images +today. JPEG uses lossy compression, and the degree of compression can be +tuned to your liking. It supports 24-bit colour depth, and since the +format is so widely used, JPEG images can be viewed and manipulated +easily on all computing platforms.

+
+
+ +
+Challenge +
+

Examining actual image sizes (optional, not +included in timing)

+
+

Let us see the effects of image compression on image size with actual +images. The following script creates a square white image 5000 x 5000 +pixels, and then saves it as a BMP and as a JPEG image.

+
+

PYTHON +

+
dim = 5000
+
+img = np.zeros((dim, dim, 3), dtype="uint8")
+img.fill(255)
+
+iio.imwrite(uri="data/ws.bmp", image=img)
+iio.imwrite(uri="data/ws.jpg", image=img)
+
+

Examine the file sizes of the two output files, ws.bmp +and ws.jpg. Does the BMP image size match our previous +prediction? How about the JPEG?

+
+
+
+
+
+ +
+
+

The BMP file, ws.bmp, is 75,000,054 bytes, which matches +our prediction very nicely. The JPEG file, ws.jpg, is +392,503 bytes, two orders of magnitude smaller than the bitmap +version.

+
+
+
+
+
+
+ +
+Challenge +
+

Comparing lossless versus lossy compression +(optional, not included in timing)

+
+

Let us see a hands-on example of lossless versus lossy compression. +Open a terminal (or Windows PowerShell) and navigate to the +data/ directory. The two output images, ws.bmp +and ws.jpg, should still be in the directory, along with +another image, tree.jpg.

+

We can apply lossless compression to any file by using the +zip command. Recall that the ws.bmp file +contains 75,000,054 bytes. Apply lossless compression to this image by +executing the following command: zip ws.zip ws.bmp +(Compress-Archive ws.bmp ws.zip with PowerShell). This +command tells the computer to create a new compressed file, +ws.zip, from the original bitmap image. Execute a similar +command on the tree JPEG file: zip tree.zip tree.jpg +(Compress-Archive tree.jpg tree.zip with PowerShell).

+

Having created the compressed file, use the ls -l +command (dir with PowerShell) to display the contents of +the directory. How big are the compressed files? How do those compare to +the size of ws.bmp and tree.jpg? What can you +conclude from the relative sizes?

+
+
+
+
+
+ +
+
+

Here is a partial directory listing, showing the sizes of the +relevant files there:

+
+

OUTPUT +

+
-rw-rw-r--  1 diva diva   154344 Jun 18 08:32 tree.jpg
+-rw-rw-r--  1 diva diva   146049 Jun 18 08:53 tree.zip
+-rw-rw-r--  1 diva diva 75000054 Jun 18 08:51 ws.bmp
+-rw-rw-r--  1 diva diva    72986 Jun 18 08:53 ws.zip
+
+

We can see that the regularity of the bitmap image (remember, it is a +5,000 x 5,000 pixel image containing only white pixels) allows the +lossless compression scheme to compress the file quite effectively. On +the other hand, compressing tree.jpg does not create a much +smaller file; this is because the JPEG image was already in a compressed +format.

+
+
+
+
+

Here is an example showing how JPEG compression might impact image +quality. Consider this image of several maize seedlings (scaled down +here from 11,339 × 11,336 pixels in order to fit the display).

+
Original image

Now, let us zoom in and look at a small section of the label in the +original, first in the uncompressed format:

+
Enlarged, uncompressed

Here is the same area of the image, but in JPEG format. We used a +fairly aggressive compression parameter to make the JPEG, in order to +illustrate the problems you might encounter with the format.

+
Enlarged, compressed

The JPEG image is of clearly inferior quality. It has less colour +variation and noticeable pixelation. Quality differences become even +more marked when one examines the colour histograms for each image. A +histogram shows how often each colour value appears in an image. The +histograms for the uncompressed (left) and compressed (right) images are +shown below:

+
Uncompressed histogram

We learn how to make histograms such as these later on in the +workshop. The differences in the colour histograms are even more +apparent than in the images themselves; clearly the colours in the JPEG +image are different from the uncompressed version.

+

If the quality settings for your JPEG images are high (and the +compression rate therefore relatively low), the images may be of +sufficient quality for your work. It all depends on how much quality you +need, and what restrictions you have on image storage space. Another +consideration may be where the images are stored. For example, +if your images are stored in the cloud and therefore must be downloaded +to your system before you use them, you may wish to use a compressed +image format to speed up file transfer time.

+

PNG

+

PNG images are well suited for storing diagrams. It uses a lossless +compression and is hence often used in web applications for +non-photographic images. The format is able to store RGB and plain +luminance (single channel, without an associated color) data, among +others. Image data is stored row-wise and then, per row, a simple +filter, like taking the difference of adjacent pixels, can be applied to +increase the compressability of the data. The filtered data is then +compressed in the next step and written out to the disk.

+

TIFF

+

TIFF images are popular with publishers, graphics designers, and +photographers. TIFF images can be uncompressed, or compressed using +either lossless or lossy compression schemes, depending on the settings +used, and so TIFF images seem to have the benefits of both the BMP and +JPEG formats. The main disadvantage of TIFF images (other than the size +of images in the uncompressed version of the format) is that they are +not universally readable by image viewing and manipulation software.

+

Metadata

+

JPEG and TIFF images support the inclusion of metadata in +images. Metadata is textual information that is contained within an +image file. Metadata holds information about the image itself, such as +when the image was captured, where it was captured, what type of camera +was used and with what settings, etc. We normally don’t see this +metadata when we view an image, but we can view it independently if we +wish to (see Accessing +Metadata, below). The important thing to be aware of at this +stage is that you cannot rely on the metadata of an image being fully +preserved when you use software to process that image. The image +reader/writer library that we use throughout this lesson, +imageio.v3, includes metadata when saving new images but +may fail to keep certain metadata fields. In any case, remember: +if metadata is important to you, take precautions to always +preserve the original files.

+
+
+ +
+Callout +
+

Accessing Metadata

+
+

imageio.v3 provides a way to display or explore the +metadata associated with an image. Metadata is served independently from +pixel data:

+
+

PYTHON +

+
# read metadata
+metadata = iio.immeta(uri="data/eight.tif")
+# display the format-specific metadata
+metadata
+
+
+

OUTPUT +

+
{'is_fluoview': False,
+ 'is_nih': False,
+ 'is_micromanager': False,
+ 'is_ome': False,
+ 'is_lsm': False,
+ 'is_reduced': False,
+ 'is_shaped': True,
+ 'is_stk': False,
+ 'is_tiled': False,
+ 'is_mdgel': False,
+ 'compression': <COMPRESSION.NONE: 1>,
+ 'predictor': 1,
+ 'is_mediacy': False,
+ 'description': '{"shape": [5, 3]}',
+ 'description1': '',
+ 'is_imagej': False,
+ 'software': 'tifffile.py',
+ 'resolution_unit': 1,
+ 'resolution': (1.0, 1.0, 'NONE')}
+
+

Many popular image editing programs have built-in metadata viewing +capabilities. A platform-independent open-source tool that allows users +to read, write, and edit metadata is ExifTool. It can handle a wide range of +file types and metadata formats but requires some technical knowledge to +be used effectively. Other software exists that can help you handle +metadata, e.g., Fiji and ImageMagick. You may want +to explore these options if you need to work with the metadata of your +images.

+
+
+
+

Summary of image formats used in this lesson

+

The following table summarises the characteristics of the BMP, JPEG, +and TIFF image formats:

+ + + + + + + + + + + + + + + + + + + + + + + + + +
FormatCompressionMetadataAdvantagesDisadvantages
BMPNoneNoneUniversally viewable, high qualityLarge file sizes
JPEGLossyYesUniversally viewable, smaller file sizeDetail may be lost
PNGLosslessYesUniversally viewable, open standard, smaller file +sizeMetadata less flexible than TIFF, RGB only
TIFFNone, lossy, or losslessYesHigh quality or smaller file sizeNot universally viewable
+
+ +
+Key Points +
+
+
  • Digital images are represented as rectangular arrays of square +pixels.
  • +
  • Digital images use a left-hand coordinate system, with the origin in +the upper left corner, the x-axis running to the right, and the y-axis +running down. Some learners may prefer to think in terms of counting +down rows for the y-axis and across columns for the x-axis. Thus, we +will make an effort to allow for both approaches in our lesson +presentation.
  • +
  • Most frequently, digital images use an additive RGB model, with +eight bits for the red, green, and blue channels.
  • +
  • scikit-image images are stored as multi-dimensional NumPy +arrays.
  • +
  • In scikit-image images, the red channel is specified first, then the +green, then the blue, i.e., RGB.
  • +
  • Lossless compression retains all the details in an image, but lossy +compression results in loss of some of the original image detail.
  • +
  • BMP images are uncompressed, meaning they have high quality but also +that their file sizes are large.
  • +
  • JPEG images use lossy compression, meaning that their file sizes are +smaller, but image quality may suffer.
  • +
  • TIFF images can be uncompressed or compressed with lossy or lossless +compression.
  • +
  • Depending on the camera or sensor, various useful pieces of +information may be stored in an image file, in the image metadata.
  • +
+
+
+
+
+ + +
+
+ + + diff --git a/03-skimage-images.html b/03-skimage-images.html new file mode 100644 index 000000000..b45e9f529 --- /dev/null +++ b/03-skimage-images.html @@ -0,0 +1,1100 @@ + +Image Processing with Python: Working with scikit-image +
+
+ + + + + +
+
+

Working with scikit-image

+

Last updated on 2026-03-20 | + + Edit this page

+ + + +
+ +
+ + + +
+

Overview

+
+
+
+
+

Questions

+
  • How can the scikit-image Python computer vision library be used to +work with images?
  • +
+
+
+
+
+
+

Objectives

+
  • Read and save images with imageio.
  • +
  • Display images with Matplotlib.
  • +
  • Resize images with scikit-image.
  • +
  • Perform simple image thresholding with NumPy array operations.
  • +
  • Extract sub-images using array slicing.
  • +
+
+
+
+
+

We have covered much of how images are represented in computer +software. In this episode we will learn some more methods for accessing +and changing digital images.

+

First, import the packages needed for this episode

+
+

PYTHON +

+
import imageio.v3 as iio
+import ipympl
+import matplotlib.pyplot as plt
+import numpy as np
+import skimage as ski
+
+%matplotlib widget
+
+

Reading, displaying, and saving images

+

Imageio provides intuitive functions for reading and writing (saving) +images. All of the popular image formats, such as BMP, PNG, JPEG, and +TIFF are supported, along with several more esoteric formats. Check the +Supported +Formats docs for a list of all formats. Matplotlib provides a large +collection of plotting utilities.

+

Let us examine a simple Python program to load, display, and save an +image to a different format. Here are the first few lines:

+
+

PYTHON +

+
"""Python program to open, display, and save an image."""
+# read image
+chair = iio.imread(uri="data/chair.jpg")
+
+

We use the iio.imread() function to read a JPEG image +entitled chair.jpg. Imageio reads the image, converts +it from JPEG into a NumPy array, and returns the array; we save the +array in a variable named chair.

+

Next, we will do something with the image:

+
+

PYTHON +

+
fig, ax = plt.subplots()
+ax.imshow(chair)
+
+

Once we have the image in the program, we first call +fig, ax = plt.subplots() so that we will have a fresh +figure with a set of axes independent from our previous calls. Next we +call ax.imshow() in order to display the image.

+

Now, we will save the image in another format:

+
+

PYTHON +

+
# save a new version in .tif format
+iio.imwrite(uri="data/chair.tif", image=chair)
+
+

The final statement in the program, +iio.imwrite(uri="data/chair.tif", image=chair), writes the +image to a file named chair.tif in the data/ +directory. The imwrite() function automatically determines +the type of the file, based on the file extension we provide. In this +case, the .tif extension causes the image to be saved as a +TIFF.

+
+
+ +
+Callout +
+

Metadata, revisited

+
+

Remember, as mentioned in the previous section, images saved with +imwrite() will not retain all metadata associated with the +original image that was loaded into Python! If the image metadata +is important to you, be sure to always keep an unchanged copy of +the original image!

+
+
+
+
+
+ +
+Callout +
+

Extensions do not always dictate file +type

+
+

The iio.imwrite() function automatically uses the file +type we specify in the file name parameter’s extension. Note that this +is not always the case. For example, if we are editing a document in +Microsoft Word, and we save the document as paper.pdf +instead of paper.docx, the file is not saved as a +PDF document.

+
+
+
+
+
+ +
+Callout +
+

Named versus positional arguments

+
+

When we call functions in Python, there are two ways we can specify +the necessary arguments. We can specify the arguments +positionally, i.e., in the order the parameters appear in the +function definition, or we can use named arguments.

+

For example, the iio.imwrite() function +definition specifies two parameters, the resource to save the image +to (e.g., a file name, an http address) and the image to write to disk. +So, we could save the chair image in the sample code above using +positional arguments like this:

+

iio.imwrite("data/chair.tif", image)

+

Since the function expects the first argument to be the file name, +there is no confusion about what "data/chair.jpg" means. +The same goes for the second argument.

+

The style we will use in this workshop is to name each argument, like +this:

+

iio.imwrite(uri="data/chair.tif", image=image)

+

This style will make it easier for you to learn how to use the +variety of functions we will cover in this workshop.

+
+
+
+
+
+ +
+Challenge +
+

Resizing an image (10 min)

+
+

Using the chair.jpg image located in the data folder, +write a Python script to read your image into a variable named +chair. Then, resize the image to 10 percent of its current +size using these lines of code:

+
+

PYTHON +

+
new_shape = (chair.shape[0] // 10, chair.shape[1] // 10, chair.shape[2])
+resized_chair = ski.transform.resize(image=chair, output_shape=new_shape)
+resized_chair = ski.util.img_as_ubyte(resized_chair)
+
+

As it is used here, the parameters to the +ski.transform.resize() function are the image to transform, +chair, the dimensions we want the new image to have, +new_shape.

+
+
+ +
+Callout +
+
+

Note that the pixel values in the new image are an approximation of +the original values and should not be confused with actual, observed +data. This is because scikit-image interpolates the pixel values when +reducing or increasing the size of an image. +ski.transform.resize has a number of optional parameters +that allow the user to control this interpolation. You can find more +details in the scikit-image +documentation.

+
+
+
+

Image files on disk are normally stored as whole numbers for space +efficiency, but transformations and other math operations often result +in conversion to floating point numbers. Using the +ski.util.img_as_ubyte() method converts it back to whole +numbers before we save it back to disk. If we don’t convert it before +saving, iio.imwrite() may not recognise it as image +data.

+

Next, write the resized image out to a new file named +resized.jpg in your data directory. Finally, use +ax.imshow() with each of your image variables to display +both images in your notebook. Don’t forget to use +fig, ax = plt.subplots() so you don’t overwrite the first +image with the second. Images may appear the same size in jupyter, but +you can see the size difference by comparing the scales for each. You +can also see the difference in file storage size on disk by hovering +your mouse cursor over the original and the new files in the Jupyter +file browser, using ls -l in your shell (dir +with Windows PowerShell), or viewing file sizes in the OS file browser +if it is configured so.

+
+
+
+
+
+ +
+
+

Here is what your Python script might look like.

+
+

PYTHON +

+
"""Python script to read an image, resize it, and save it under a different name."""
+
+# read in image
+chair = iio.imread(uri="data/chair.jpg")
+
+# resize the image
+new_shape = (chair.shape[0] // 10, chair.shape[1] // 10, chair.shape[2])
+resized_chair = ski.transform.resize(image=chair, output_shape=new_shape)
+resized_chair = ski.util.img_as_ubyte(resized_chair)
+
+# write out image
+iio.imwrite(uri="data/resized_chair.jpg", image=resized_chair)
+
+# display images
+fig, ax = plt.subplots()
+ax.imshow(chair)
+fig, ax = plt.subplots()
+ax.imshow(resized_chair)
+
+

The script resizes the data/chair.jpg image by a factor +of 10 in both dimensions, saves the result to the +data/resized_chair.jpg file, and displays original and +resized for comparision.

+
+
+
+
+

Manipulating pixels

+

In the Image Basics +episode, we individually manipulated the colours of pixels by +changing the numbers stored in the image’s NumPy array. Let’s apply the +principles learned there along with some new principles to a real world +example.

+

Suppose we are interested in this maize root cluster image. We want +to be able to focus our program’s attention on the roots themselves, +while ignoring the black background.

+
Root cluster image

Since the image is stored as an array of numbers, we can simply look +through the array for pixel colour values that are less than some +threshold value. This process is called thresholding, and we +will see more powerful methods to perform the thresholding task in the Thresholding episode. Here, +though, we will look at a simple and elegant NumPy method for +thresholding. Let us develop a program that keeps only the pixel colour +values in an image that have value greater than or equal to 128. This +will keep the pixels that are brighter than half of “full brightness”, +i.e., pixels that do not belong to the black background.

+

We will start by reading the image and displaying it.

+
+
+ +
+Callout +
+

Loading images with imageio: Read-only +arrays

+
+

When loading an image with imageio, in certain situations the image +is stored in a read-only array. If you attempt to manipulate the pixels +in a read-only array, you will receive an error message +ValueError: assignment destination is read-only. In order +to make the image array writeable, we can create a copy with +image = np.array(image) before manipulating the pixel +values.

+
+
+
+
+

PYTHON +

+
"""Python script to ignore low intensity pixels in an image."""
+
+# read input image
+maize_roots = iio.imread(uri="data/maize-root-cluster.jpg")
+maize_roots = np.array(maize_roots)
+
+# display original image
+fig, ax = plt.subplots()
+ax.imshow(maize_roots)
+
+

Now we can threshold the image and display the result.

+
+

PYTHON +

+
# keep only high-intensity pixels
+maize_roots[maize_roots < 128] = 0
+
+# display modified image
+fig, ax = plt.subplots()
+ax.imshow(maize_roots)
+
+

The NumPy command to ignore all low-intensity pixels is +roots[roots < 128] = 0. Every pixel colour value in the +whole 3-dimensional array with a value less that 128 is set to zero. In +this case, the result is an image in which the extraneous background +detail has been removed.

+
Thresholded root image

Converting colour images to grayscale

+

It is often easier to work with grayscale images, which have a single +channel, instead of colour images, which have three channels. +scikit-image offers the function ski.color.rgb2gray() to +achieve this. This function adds up the three colour channels in a way +that matches human colour perception, see the +scikit-image documentation for details. It returns a grayscale image +with floating point values in the range from 0 to 1. We can use the +function ski.util.img_as_ubyte() in order to convert it +back to the original data type and the data range back 0 to 255. Note +that it is often better to use image values represented by floating +point values, because using floating point numbers is numerically more +stable.

+
+
+ +
+Callout +
+

Colour and color +

+
+

The Carpentries generally prefers UK English spelling, which is why +we use “colour” in the explanatory text of this lesson. However, +scikit-image contains many modules and functions that include the US +English spelling, color. The exact spelling matters here, +e.g. you will encounter an error if you try to run +ski.colour.rgb2gray(). To account for this, we will use the +US English spelling, color, in example Python code +throughout the lesson. You will encounter a similar approach with +“centre” and center.

+
+
+
+
+

PYTHON +

+
"""Python script to load a color image as grayscale."""
+
+# read input image
+chair = iio.imread(uri="data/chair.jpg")
+
+# display original image
+fig, ax = plt.subplots()
+ax.imshow(chair)
+
+# convert to grayscale and display
+gray_chair = ski.color.rgb2gray(chair)
+fig, ax = plt.subplots()
+ax.imshow(gray_chair, cmap="gray")
+
+

We can also load colour images as grayscale directly by passing the +argument mode="L" to iio.imread().

+
+

PYTHON +

+
"""Python script to load a color image as grayscale."""
+
+# read input image, based on filename parameter
+gray_chair = iio.imread(uri="data/chair.jpg", mode="L")
+
+# display grayscale image
+fig, ax = plt.subplots()
+ax.imshow(gray_chair, cmap="gray")
+
+

The first argument to iio.imread() is the filename of +the image. The second argument mode="L" determines the type +and range of the pixel values in the image (e.g., an 8-bit pixel has a +range of 0-255). This argument is forwarded to the pillow +backend, a Python imaging library for which mode “L” means 8-bit pixels +and single-channel (i.e., grayscale). The backend used by +iio.imread() may be specified as an optional argument: to +use pillow, you would pass plugin="pillow". If +the backend is not specified explicitly, iio.imread() +determines the backend to use based on the image type.

+
+
+ +
+Callout +
+

Loading images with imageio: Pixel type and +depth

+
+

When loading an image with mode="L", the pixel values +are stored as 8-bit integer numbers that can take values in the range +0-255. However, pixel values may also be stored with other types and +ranges. For example, some scikit-image functions return the pixel values +as floating point numbers in the range 0-1. The type and range of the +pixel values are important for the colorscale when plotting, and for +masking and thresholding images as we will see later in the lesson. If +you are unsure about the type of the pixel values, you can inspect it +with print(image.dtype). For the example above, you should +find that it is dtype('uint8') indicating 8-bit integer +numbers.

+
+
+
+
+
+ +
+Challenge +
+

Keeping only low intensity pixels (10 +min)

+
+

A little earlier, we showed how we could use Python and scikit-image +to turn on only the high intensity pixels from an image, while turning +all the low intensity pixels off. Now, you can practice doing the +opposite - keeping all the low intensity pixels while changing the high +intensity ones.

+

The file data/sudoku.png is an RGB image of a sudoku +puzzle:

+
Su-Do-Ku puzzle

Your task is to load the image in grayscale format and turn all of +the bright pixels in the image to a light gray colour. In other words, +mask the bright pixels that have a pixel value greater than, say, 192 +and set their value to 192 (the value 192 is chosen here because it +corresponds to 75% of the range 0-255 of an 8-bit pixel). The results +should look like this:

+
Modified Su-Do-Ku puzzle

Hint: the cmap, vmin, and +vmax parameters of matplotlib.pyplot.imshow +will be needed to display the modified image as desired. See the Matplotlib +documentation for more details on cmap, +vmin, and vmax.

+
+
+
+
+
+ +
+
+

First, load the image file data/sudoku.png as a +grayscale image. Note we may want to create a copy of the image array to +avoid modifying our original variable and also because +imageio.v3.imread sometimes returns a non-writeable +image.

+
+

PYTHON +

+
sudoku = iio.imread(uri="data/sudoku.png", mode="L")
+sudoku_gray_background = np.array(sudoku)
+
+

Then change all bright pixel values greater than 192 to 192:

+
+

PYTHON +

+
sudoku_gray_background[sudoku_gray_background > 192] = 192
+
+

Finally, display the original and modified images side by side. Note +that we have to specify vmin=0 and vmax=255 as +the range of the colorscale because it would otherwise automatically +adjust to the new range 0-192.

+
+

PYTHON +

+
fig, ax = plt.subplots(ncols=2)
+ax[0].imshow(sudoku, cmap="gray", vmin=0, vmax=255)
+ax[1].imshow(sudoku_gray_background, cmap="gray", vmin=0, vmax=255)
+
+
+
+
+
+
+
+ +
+Callout +
+

Plotting single channel images (cmap, vmin, +vmax)

+
+

Compared to a colour image, a grayscale image contains only a single +intensity value per pixel. When we plot such an image with +ax.imshow, Matplotlib uses a colour map, to assign each +intensity value a colour. The default colour map is called “viridis” and +maps low values to purple and high values to yellow. We can instruct +Matplotlib to map low values to black and high values to white instead, +by calling ax.imshow with cmap="gray". The +documentation contains an overview of pre-defined colour maps.

+

Furthermore, Matplotlib determines the minimum and maximum values of +the colour map dynamically from the image, by default. That means that +in an image where the minimum is 64 and the maximum is 192, those values +will be mapped to black and white respectively (and not dark gray and +light gray as you might expect). If there are defined minimum and +maximum vales, you can specify them via vmin and +vmax to get the desired output.

+

If you forget about this, it can lead to unexpected results. Try +removing the vmax parameter from the sudoku challenge +solution and see what happens.

+
+
+
+

Access via slicing

+

As noted in the previous lesson scikit-image images are stored as +NumPy arrays, so we can use array slicing to select rectangular areas of +an image. Then, we can save the selection as a new image, change the +pixels in the image, and so on. It is important to remember that +coordinates are specified in (ry, cx) order and that colour +values are specified in (r, g, b) order when doing these +manipulations.

+

Consider this image of a whiteboard, and suppose that we want to +create a sub-image with just the portion that says “odd + even = odd,” +along with the red box that is drawn around the words.

+
Whiteboard image

Using matplotlib.pyplot.imshow we can determine the +coordinates of the corners of the area we wish to extract by hovering +the mouse near the points of interest and noting the coordinates +(remember to run %matplotlib widget first if you haven’t +already). If we do that, we might settle on a rectangular area with an +upper-left coordinate of (135, 60) and a lower-right coordinate +of (480, 150), as shown in this version of the whiteboard +picture:

+
Whiteboard coordinates

Note that the coordinates in the preceding image are specified in +(cx, ry) order. Now if our entire whiteboard image is stored as +a NumPy array named image, we can create a new image of the +selected region with a statement like this:

+

clip = image[60:151, 135:481, :]

+

Our array slicing specifies the range of y-coordinates or rows first, +60:151, and then the range of x-coordinates or columns, +135:481. Note we go one beyond the maximum value in each +dimension, so that the entire desired area is selected. The third part +of the slice, :, indicates that we want all three colour +channels in our new image.

+

A script to create the subimage would start by loading the image:

+
+

PYTHON +

+
"""Python script demonstrating image modification and creation via NumPy array slicing."""
+
+# load and display original image
+board = iio.imread(uri="data/board.jpg")
+board = np.array(board)
+fig, ax = plt.subplots()
+ax.imshow(board)
+
+

Then we use array slicing to create a new image with our selected +area and then display the new image.

+
+

PYTHON +

+
# extract, display, and save sub-image
+clipped_board = board[60:151, 135:481, :]
+fig, ax = plt.subplots()
+ax.imshow(clipped_board)
+iio.imwrite(uri="data/clipped_board.tif", image=clipped_board)
+
+

We can also change the values in an image, as shown next.

+
+

PYTHON +

+
# replace clipped area with sampled color
+color = board[330, 90]
+board[60:151, 135:481] = color
+fig, ax = plt.subplots()
+ax.imshow(board)
+
+

First, we sample a single pixel’s colour at a particular location of +the image, saving it in a variable named color, which +creates a 1 × 1 × 3 NumPy array with the blue, green, and red colour +values for the pixel located at (ry = 330, cx = 90). Then, with +the img[60:151, 135:481] = color command, we modify the +image in the specified area. From a NumPy perspective, this changes all +the pixel values within that range to array saved in the +color variable. In this case, the command “erases” that +area of the whiteboard, replacing the words with a beige colour, as +shown in the final image produced by the program:

+
"Erased" whiteboard
+
+ +
+Challenge +
+

Practicing with slices (10 min - optional, not +included in timing)

+
+

Using the techniques you just learned, write a script that creates, +displays, and saves a sub-image containing only the plant and its roots +from “data/maize-root-cluster.jpg”

+
+
+
+
+
+ +
+
+

Here is the completed Python program to select only the plant and +roots in the image.

+
+

PYTHON +

+
"""Python script to extract a sub-image containing only the plant and roots in an existing image."""
+
+# load and display original image
+maize_roots = iio.imread(uri="data/maize-root-cluster.jpg")
+fig, ax = plt.subplots()
+ax.imshow(maize_roots)
+
+# extract and display sub-image
+clipped_maize = maize_roots[0:400, 275:550, :]
+fig, ax = plt.subplots()
+ax.imshow(clipped_maize)
+
+
+# save sub-image
+iio.imwrite(uri="data/clipped_maize.jpg", image=clipped_maize)
+
+
+
+
+
+
+
+ +
+Key Points +
+
+
  • Images are read from disk with the iio.imread() +function.
  • +
  • We create a window that automatically scales the displayed image +with Matplotlib and calling imshow() on the global figure +object.
  • +
  • Colour images can be transformed to grayscale using +ski.color.rgb2gray() or, in many cases, be read as +grayscale directly by passing the argument mode="L" to +iio.imread().
  • +
  • We can resize images with the ski.transform.resize() +function.
  • +
  • NumPy array commands, such as +image[image < 128] = 0, can be used to manipulate the +pixels of an image.
  • +
  • Array slicing can be used to extract sub-images or modify areas of +images, e.g., clip = image[60:150, 135:480, :].
  • +
  • Metadata is not retained when images are loaded as NumPy arrays +using iio.imread().
  • +
+
+
+
+
+ + +
+
+ + + diff --git a/04-drawing.html b/04-drawing.html new file mode 100644 index 000000000..b0d568abf --- /dev/null +++ b/04-drawing.html @@ -0,0 +1,1052 @@ + +Image Processing with Python: Drawing and Bitwise Operations +
+
+ + + + + +
+
+

Drawing and Bitwise Operations

+

Last updated on 2026-03-20 | + + Edit this page

+ + + +
+ +
+ + + +
+

Overview

+
+
+
+
+

Questions

+
  • How can we draw on scikit-image images and use bitwise operations +and masks to select certain parts of an image?
  • +
+
+
+
+
+
+

Objectives

+
  • Create a blank, black scikit-image image.
  • +
  • Draw rectangles and other shapes on scikit-image images.
  • +
  • Explain how a white shape on a black background can be used as a +mask to select specific parts of an image.
  • +
  • Use bitwise operations to apply a mask to an image.
  • +
+
+
+
+
+

The next series of episodes covers a basic toolkit of scikit-image +operators. With these tools, we will be able to create programs to +perform simple analyses of images based on changes in colour or +shape.

+

First, import the packages needed for this episode

+
+

PYTHON +

+
import imageio.v3 as iio
+import ipympl
+import matplotlib.pyplot as plt
+import numpy as np
+import skimage as ski
+
+%matplotlib widget
+
+

Here, we import the same packages as earlier in the lesson.

+

Drawing on images

+

Often we wish to select only a portion of an image to analyze, and +ignore the rest. Creating a rectangular sub-image with slicing, as we +did in the Working with +scikit-image episode is one option for simple cases. Another +option is to create another special image, of the same size as the +original, with white pixels indicating the region to save and black +pixels everywhere else. Such an image is called a mask. In +preparing a mask, we sometimes need to be able to draw a shape - a +circle or a rectangle, say - on a black image. scikit-image provides +tools to do that.

+

Consider this image of maize seedlings:

+
Maize seedlings

Now, suppose we want to analyze only the area of the image containing +the roots themselves; we do not care to look at the kernels, or anything +else about the plants. Further, we wish to exclude the frame of the +container holding the seedlings as well. Hovering over the image with +our mouse, could tell us that the upper-left coordinate of the sub-area +we are interested in is (44, 357), while the lower-right +coordinate is (720, 740). These coordinates are shown in +(x, y) order.

+

A Python program to create a mask to select only that area of the +image would start with a now-familiar section of code to open and +display the original image:

+
+

PYTHON +

+
# Load and display the original image
+maize_seedlings = iio.imread(uri="data/maize-seedlings.tif")
+
+fig, ax = plt.subplots()
+ax.imshow(maize_seedlings)
+
+

We load and display the initial image in the same way we have done +before.

+

NumPy allows indexing of images/arrays with “boolean” arrays of the +same size. Indexing with a boolean array is also called mask indexing. +The “pixels” in such a mask array can only take two values: +True or False. When indexing an image with +such a mask, only pixel values at positions where the mask is +True are accessed. But first, we need to generate a mask +array of the same size as the image. Luckily, the NumPy library provides +a function to create just such an array. The next section of code shows +how:

+
+

PYTHON +

+
# Create the basic mask
+mask = np.ones(shape=maize_seedlings.shape[0:2], dtype="bool")
+
+

The first argument to the ones() function is the shape +of the original image, so that our mask will be exactly the same size as +the original. Notice, that we have only used the first two indices of +our shape. We omitted the channel dimension. Indexing with such a mask +will change all channel values simultaneously. The second argument, +dtype = "bool", indicates that the elements in the array +should be booleans - i.e., values are either True or +False. Thus, even though we use np.ones() to +create the mask, its pixel values are in fact not 1 but +True. You could check this, e.g., by +print(mask[0, 0]).

+

Next, we draw a filled, rectangle on the mask:

+
+

PYTHON +

+
# Draw filled rectangle on the mask image
+rr, cc = ski.draw.rectangle(start=(357, 44), end=(740, 720))
+mask[rr, cc] = False
+
+# Display mask image
+fig, ax = plt.subplots()
+ax.imshow(mask, cmap="gray")
+
+

Here is what our constructed mask looks like: Maize image mask

+

The parameters of the rectangle() function +(357, 44) and (740, 720), are the coordinates +of the upper-left (start) and lower-right +(end) corners of a rectangle in (ry, cx) order. +The function returns the rectangle as row (rr) and column +(cc) coordinate arrays.

+
+
+ +
+Callout +
+

Check the documentation!

+
+

When using an scikit-image function for the first time - or the fifth +time - it is wise to check how the function is used, via the scikit-image +documentation or other usage examples on programming-related sites +such as Stack Overflow. Basic +information about scikit-image functions can be found interactively in +Python, via commands like help(ski) or +help(ski.draw.rectangle). Take notes in your lab notebook. +And, it is always wise to run some test code to verify that the +functions your program uses are behaving in the manner you intend.

+
+
+
+
+
+ +
+Callout +
+

Variable naming conventions!

+
+

You may have wondered why we called the return values of the +rectangle function rr and cc?! You may have +guessed that r is short for row and +c is short for column. However, the rectangle +function returns mutiple rows and columns; thus we used a convention of +doubling the letter r to rr (and +c to cc) to indicate that those are multiple +values. In fact it may have even been clearer to name those variables +rows and columns; however this would have been +also much longer. Whatever you decide to do, try to stick to some +already existing conventions, such that it is easier for other people to +understand your code.

+
+
+
+
+
+ +
+Challenge +
+

Other drawing operations (15 min)

+
+

There are other functions for drawing on images, in addition to the +ski.draw.rectangle() function. We can draw circles, lines, +text, and other shapes as well. These drawing functions may be useful +later on, to help annotate images that our programs produce. Practice +some of these functions here.

+

Circles can be drawn with the ski.draw.disk() function, +which takes two parameters: the (ry, cx) point of the centre of the +circle, and the radius of the circle. There is an optional +shape parameter that can be supplied to this function. It +will limit the output coordinates for cases where the circle dimensions +exceed the ones of the image.

+

Lines can be drawn with the ski.draw.line() function, +which takes four parameters: the (ry, cx) coordinate of one end of the +line, and the (ry, cx) coordinate of the other end of the line.

+

Other drawing functions supported by scikit-image can be found in the +scikit-image reference pages.

+

First let’s make an empty, black image with a size of 800x600 pixels. +Recall that a colour image has three channels for the colours red, +green, and blue (RGB, cf. Image +Basics). Hence we need to create a 3D array of shape +(600, 800, 3) where the last dimension represents the RGB +colour channels.

+
+

PYTHON +

+
# create the black canvas
+canvas = np.zeros(shape=(600, 800, 3), dtype="uint8")
+
+

Now your task is to draw some other coloured shapes and lines on the +image, perhaps something like this:

+
Sample shapes
+
+
+
+
+ +
+
+

Drawing a circle:

+
+

PYTHON +

+
# Draw a blue circle with centre (200, 300) in (ry, cx) coordinates, and radius 100
+rr, cc = ski.draw.disk(center=(200, 300), radius=100, shape=canvas.shape[0:2])
+canvas[rr, cc] = (0, 0, 255)
+
+

Drawing a line:

+
+

PYTHON +

+
# Draw a green line from (400, 200) to (500, 700) in (ry, cx) coordinates
+rr, cc = ski.draw.line(r0=400, c0=200, r1=500, c1=700)
+canvas[rr, cc] = (0, 255, 0)
+
+
+

PYTHON +

+
# Display the image
+fig, ax = plt.subplots()
+ax.imshow(canvas)
+
+

We could expand this solution, if we wanted, to draw rectangles, +circles and lines at random positions within our black canvas. To do +this, we could use the random python module, and the +function random.randrange, which can produce random numbers +within a certain range.

+

Let’s draw 15 randomly placed circles:

+
+

PYTHON +

+
import random
+
+# create the black canvas
+canvas = np.zeros(shape=(600, 800, 3), dtype="uint8")
+
+# draw a blue circle at a random location 15 times
+for i in range(15):
+    rr, cc = ski.draw.disk(center=(
+         random.randrange(600),
+         random.randrange(800)),
+         radius=50,
+         shape=canvas.shape[0:2],
+        )
+    canvas[rr, cc] = (0, 0, 255)
+
+# display the results
+fig, ax = plt.subplots()
+ax.imshow(canvas)
+
+

We could expand this even further to also randomly choose whether to +plot a rectangle, a circle, or a square. Again, we do this with the +random module, now using the function +random.random that returns a random number between 0.0 and +1.0.

+
+

PYTHON +

+
import random
+
+# Draw 15 random shapes (rectangle, circle or line) at random positions
+for i in range(15):
+    # generate a random number between 0.0 and 1.0 and use this to decide if we
+    # want a circle, a line or a sphere
+    x = random.random()
+    if x < 0.33:
+        # draw a blue circle at a random location
+        rr, cc = ski.draw.disk(center=(
+            random.randrange(600),
+            random.randrange(800)),
+            radius=50,
+            shape=canvas.shape[0:2],
+        )
+        color = (0, 0, 255)
+    elif x < 0.66:
+        # draw a green line at a random location
+        rr, cc = ski.draw.line(
+            r0=random.randrange(600),
+            c0=random.randrange(800),
+            r1=random.randrange(600),
+            c1=random.randrange(800),
+        )
+        color = (0, 255, 0)
+    else:
+        # draw a red rectangle at a random location
+        rr, cc = ski.draw.rectangle(
+            start=(random.randrange(600), random.randrange(800)),
+            extent=(50, 50),
+            shape=canvas.shape[0:2],
+        )
+        color = (255, 0, 0)
+
+    canvas[rr, cc] = color
+
+# display the results
+fig, ax = plt.subplots()
+ax.imshow(canvas)
+
+
+
+
+
+

Image modification

+

All that remains is the task of modifying the image using our mask in +such a way that the areas with True pixels in the mask are +not shown in the image any more.

+
+
+ +
+Challenge +
+

How does a mask work? (optional, not included +in timing)

+
+

Now, consider the mask image we created above. The values of the mask +that corresponds to the portion of the image we are interested in are +all False, while the values of the mask that corresponds to +the portion of the image we want to remove are all +True.

+

How do we change the original image using the mask?

+
+
+
+
+
+ +
+
+

When indexing the image using the mask, we access only those pixels +at positions where the mask is True. So, when indexing with +the mask, one can set those values to 0, and effectively remove them +from the image.

+
+
+
+
+

Now we can write a Python program to use a mask to retain only the +portions of our maize roots image that actually contains the seedling +roots. We load the original image and create the mask in the same way as +before:

+
+

PYTHON +

+
# Load the original image
+maize_seedlings = iio.imread(uri="data/maize-seedlings.tif")
+
+# Create the basic mask
+mask = np.ones(shape=maize_seedlings.shape[0:2], dtype="bool")
+
+# Draw a filled rectangle on the mask image
+rr, cc = ski.draw.rectangle(start=(357, 44), end=(740, 720))
+mask[rr, cc] = False
+
+

Then, we use NumPy indexing to remove the portions of the image, +where the mask is True:

+
+

PYTHON +

+
# Apply the mask
+maize_seedlings[mask] = 0
+
+

Then, we display the masked image.

+
+

PYTHON +

+
fig, ax = plt.subplots()
+ax.imshow(maize_seedlings)
+
+

The resulting masked image should look like this:

+
Applied mask
+
+ +
+Challenge +
+

Masking an image of your own (optional, not +included in timing)

+
+

Now, it is your turn to practice. Using your mobile phone, tablet, +webcam, or digital camera, take an image of an object with a simple +overall geometric shape (think rectangular or circular). Copy that image +to your computer, write some code to make a mask, and apply it to select +the part of the image containing your object. For example, here is an +image of a remote control:

+
Remote control image

And, here is the end result of a program masking out everything but +the remote:

+
Remote control masked
+
+
+
+
+ +
+
+

Here is a Python program to produce the cropped remote control image +shown above. Of course, your program should be tailored to your +image.

+
+

PYTHON +

+
# Load the image
+remote = iio.imread(uri="data/remote-control.jpg")
+remote = np.array(remote)
+
+# Create the basic mask
+mask = np.ones(shape=remote.shape[0:2], dtype="bool")
+
+# Draw a filled rectangle on the mask image
+rr, cc = ski.draw.rectangle(start=(93, 1107), end=(1821, 1668))
+mask[rr, cc] = False
+
+# Apply the mask
+remote[mask] = 0
+
+# Display the result
+fig, ax = plt.subplots()
+ax.imshow(remote)
+
+
+
+
+
+
+
+ +
+Challenge +
+

Masking a 96-well plate image (30 min)

+
+

Consider this image of a 96-well plate that has been scanned on a +flatbed scanner.

+
+

PYTHON +

+
# Load the image
+wellplate = iio.imread(uri="data/wellplate-01.jpg")
+wellplate = np.array(wellplate)
+
+# Display the image
+fig, ax = plt.subplots()
+ax.imshow(wellplate)
+
+
96-well plate

Suppose that we are interested in the colours of the solutions in +each of the wells. We do not care about the colour of the rest +of the image, i.e., the plastic that makes up the well plate itself.

+

Your task is to write some code that will produce a mask that will +mask out everything except for the wells. To help with this, you should +use the text file data/centers.txt that contains the (cx, +ry) coordinates of the centre of each of the 96 wells in this image. You +may assume that each of the wells has a radius of 16 pixels.

+

Your program should produce output that looks like this:

+
Masked 96-well plate

Hint: You can load data/centers.txt using:

+
+

PYTHON +

+
# load the well coordinates as a NumPy array
+centers = np.loadtxt("data/centers.txt", delimiter=" ")
+
+
+
+
+
+
+ +
+
+
+

PYTHON +

+
# load the well coordinates as a NumPy array
+centers = np.loadtxt("data/centers.txt", delimiter=" ")
+
+# read in original image
+wellplate = iio.imread(uri="data/wellplate-01.jpg")
+wellplate = np.array(wellplate)
+
+# create the mask image
+mask = np.ones(shape=wellplate.shape[0:2], dtype="bool")
+
+# iterate through the well coordinates
+for cx, ry in centers:
+    # draw a circle on the mask at the well center
+    rr, cc = ski.draw.disk(center=(ry, cx), radius=16, shape=wellplate.shape[:2])
+    mask[rr, cc] = False
+
+# apply the mask
+wellplate[mask] = 0
+
+# display the result
+fig, ax = plt.subplots()
+ax.imshow(wellplate)
+
+
+
+
+
+
+
+ +
+Challenge +
+

Masking a 96-well plate image, take two +(optional, not included in timing)

+
+

If you spent some time looking at the contents of the +data/centers.txt file from the previous challenge, you may +have noticed that the centres of each well in the image are very +regular. Assuming that the images are scanned in such a way +that the wells are always in the same place, and that the image is +perfectly oriented (i.e., it does not slant one way or another), we +could produce our well plate mask without having to read in the +coordinates of the centres of each well. Assume that the centre of the +upper left well in the image is at location cx = 91 and ry = 108, and +that there are 70 pixels between each centre in the cx dimension and 72 +pixels between each centre in the ry dimension. Each well still has a +radius of 16 pixels. Write a Python program that produces the same +output image as in the previous challenge, but without having +to read in the centers.txt file. Hint: use nested for +loops.

+
+
+
+
+
+ +
+
+

Here is a Python program that is able to create the masked image +without having to read in the centers.txt file.

+
+

PYTHON +

+
# read in original image
+wellplate = iio.imread(uri="data/wellplate-01.jpg")
+wellplate = np.array(wellplate)
+
+# create the mask image
+mask = np.ones(shape=wellplate.shape[0:2], dtype="bool")
+
+# upper left well coordinates
+cx0 = 91
+ry0 = 108
+
+# spaces between wells
+deltaCX = 70
+deltaRY = 72
+
+cx = cx0
+ry = ry0
+
+# iterate each row and column
+for row in range(12):
+    # reset cx to leftmost well in the row
+    cx = cx0
+    for col in range(8):
+
+        # ... and drawing a circle on the mask
+        rr, cc = ski.draw.disk(center=(ry, cx), radius=16, shape=wellplate.shape[0:2])
+        mask[rr, cc] = False
+        cx += deltaCX
+    # after one complete row, move to next row
+    ry += deltaRY
+
+# apply the mask
+wellplate[mask] = 0
+
+# display the result
+fig, ax = plt.subplots()
+ax.imshow(wellplate)
+
+
+
+
+
+
+
+ +
+Key Points +
+
+
  • We can use the NumPy zeros() function to create a +blank, black image.
  • +
  • We can draw on scikit-image images with functions such as +ski.draw.rectangle(), ski.draw.disk(), +ski.draw.line(), and more.
  • +
  • The drawing functions return indices to pixels that can be set +directly.
  • +
+
+
+
+
+ + +
+
+ + + diff --git a/05-creating-histograms.html b/05-creating-histograms.html new file mode 100644 index 000000000..4802cb117 --- /dev/null +++ b/05-creating-histograms.html @@ -0,0 +1,896 @@ + +Image Processing with Python: Creating Histograms +
+
+ + + + + +
+
+

Creating Histograms

+

Last updated on 2026-03-20 | + + Edit this page

+ + + +
+ +
+ + + +
+

Overview

+
+
+
+
+

Questions

+
  • How can we create grayscale and colour histograms to understand the +distribution of colour values in an image?
  • +
+
+
+
+
+
+

Objectives

+
  • Explain what a histogram is.
  • +
  • Load an image in grayscale format.
  • +
  • Create and display grayscale and colour histograms for entire +images.
  • +
  • Create and display grayscale and colour histograms for certain areas +of images, via masks.
  • +
+
+
+
+
+

In this episode, we will learn how to use scikit-image functions to +create and display histograms for images.

+

First, import the packages needed for this episode

+
+

PYTHON +

+
import imageio.v3 as iio
+import ipympl
+import matplotlib.pyplot as plt
+import numpy as np
+import skimage as ski
+
+%matplotlib widget
+
+

Introduction to Histograms

+

As it pertains to images, a histogram is a graphical +representation showing how frequently various colour values occur in the +image. We saw in the Image +Basics episode that we could use a histogram to visualise the +differences in uncompressed and compressed image formats. If your +project involves detecting colour changes between images, histograms +will prove to be very useful, and histograms are also quite handy as a +preparatory step before performing thresholding.

+

Grayscale Histograms

+

We will start with grayscale images, and then move on to colour +images. We will use this image of a plant seedling as an example: Plant seedling

+

Here we load the image in grayscale instead of full colour, and +display it:

+
+

PYTHON +

+
# read the image of a plant seedling as grayscale from the outset
+plant_seedling = iio.imread(uri="data/plant-seedling.jpg", mode="L")
+
+# convert the image to float dtype with a value range from 0 to 1
+plant_seedling = ski.util.img_as_float(plant_seedling)
+
+# display the image
+fig, ax = plt.subplots()
+ax.imshow(plant_seedling, cmap="gray")
+
+
Plant seedling

Again, we use the iio.imread() function to load our +image. Then, we convert the grayscale image of integer dtype, with 0-255 +range, into a floating-point one with 0-1 range, by calling the function +ski.util.img_as_float. We can also calculate histograms for +8 bit images as we will see in the subsequent exercises.

+

We now use the function np.histogram to compute the +histogram of our image which, after all, is a NumPy array:

+
+

PYTHON +

+
# create the histogram
+histogram, bin_edges = np.histogram(plant_seedling, bins=256, range=(0, 1))
+
+

The parameter bins determines the number of “bins” to +use for the histogram. We pass in 256 because we want to +see the pixel count for each of the 256 possible values in the grayscale +image.

+

The parameter range is the range of values each of the +pixels in the image can have. Here, we pass 0 and 1, which is the value +range of our input image after conversion to floating-point.

+

The first output of the np.histogram function is a +one-dimensional NumPy array, with 256 rows and one column, representing +the number of pixels with the intensity value corresponding to the +index. I.e., the first number in the array is the number of pixels found +with intensity value 0, and the final number in the array is the number +of pixels found with intensity value 255. The second output of +np.histogram is an array with the bin edges and one column +and 257 rows (one more than the histogram itself). There are no gaps +between the bins, which means that the end of the first bin, is the +start of the second and so on. For the last bin, the array also has to +contain the stop, so it has one more element, than the histogram.

+

Next, we turn our attention to displaying the histogram, by taking +advantage of the plotting facilities of the Matplotlib library.

+
+

PYTHON +

+
# configure and draw the histogram figure
+fig, ax = plt.subplots()
+ax.set_title("Grayscale Histogram")
+ax.set_xlabel("grayscale value")
+ax.set_ylabel("pixel count")
+ax.set_xlim([0.0, 1.0])  # <- named arguments do not work here
+
+ax.plot(bin_edges[0:-1], histogram)  # <- or here
+
+

We create the plot with plt.subplots(), then label the +figure and the coordinate axes with ax.set_title(), +ax.set_xlabel(), and ax.set_ylabel() +functions. The last step in the preparation of the figure is to set the +limits on the values on the x-axis with the +ax.set_xlim([0.0, 1.0]) function call.

+
+
+ +
+Callout +
+

Variable-length argument lists

+
+

Note that we cannot used named parameters for the +ax.set_xlim() or ax.plot() functions. This is +because these functions are defined to take an arbitrary number of +unnamed arguments. The designers wrote the functions this way +because they are very versatile, and creating named parameters for all +of the possible ways to use them would be complicated.

+
+
+
+

Finally, we create the histogram plot itself with +ax.plot(bin_edges[0:-1], histogram). We use the +left bin edges as x-positions for the histogram values +by indexing the bin_edges array to ignore the last value +(the right edge of the last bin). When we run the +program on this image of a plant seedling, it produces this +histogram:

+
Plant seedling histogram
+
+ +
+Callout +
+

Histograms in Matplotlib

+
+

Matplotlib provides a dedicated function to compute and display +histograms: ax.hist(). We will not use it in this lesson in +order to understand how to calculate histograms in more detail. In +practice, it is a good idea to use this function, because it visualises +histograms more appropriately than ax.plot(). Here, you +could use it by calling +ax.hist(image.flatten(), bins=256, range=(0, 1)) instead of +np.histogram() and ax.plot() +(*.flatten() is a NumPy function that converts our +two-dimensional image into a one-dimensional array).

+
+
+
+
+
+ +
+Challenge +
+

Using a mask for a histogram (15 min)

+
+

Looking at the histogram above, you will notice that there is a large +number of very dark pixels, as indicated in the chart by the spike +around the grayscale value 0.12. That is not so surprising, since the +original image is mostly black background. What if we want to focus more +closely on the leaf of the seedling? That is where a mask enters the +picture!

+

First, hover over the plant seedling image with your mouse to +determine the (x, y) coordinates of a bounding box around the +leaf of the seedling. Then, using techniques from the Drawing and Bitwise Operations +episode, create a mask with a white rectangle covering that bounding +box.

+

After you have created the mask, apply it to the input image before +passing it to the np.histogram function.

+
+
+
+
+
+ +
+
+
+

PYTHON +

+

+# read the image as grayscale from the outset
+plant_seedling = iio.imread(uri="data/plant-seedling.jpg", mode="L")
+
+# convert the image to float dtype with a value range from 0 to 1
+plant_seedling = ski.util.img_as_float(plant_seedling)
+
+# display the image
+fig, ax = plt.subplots()
+ax.imshow(plant_seedling, cmap="gray")
+
+# create mask here, using np.zeros() and ski.draw.rectangle()
+mask = np.zeros(shape=plant_seedling.shape, dtype="bool")
+rr, cc = ski.draw.rectangle(start=(199, 410), end=(384, 485))
+mask[rr, cc] = True
+
+# display the mask
+fig, ax = plt.subplots()
+ax.imshow(mask, cmap="gray")
+
+# mask the image and create the new histogram
+histogram, bin_edges = np.histogram(plant_seedling[mask], bins=256, range=(0.0, 1.0))
+
+# configure and draw the histogram figure
+fig, ax = plt.subplots()
+
+ax.set_title("Grayscale Histogram")
+ax.set_xlabel("grayscale value")
+ax.set_ylabel("pixel count")
+ax.set_xlim([0.0, 1.0])
+ax.plot(bin_edges[0:-1], histogram)
+
+

Your histogram of the masked area should look something like +this:

+
Grayscale histogram of masked area
+
+
+
+

Colour Histograms

+

We can also create histograms for full colour images, in addition to +grayscale histograms. We have seen colour histograms before, in the Image Basics episode. A +program to create colour histograms starts in a familiar way:

+
+

PYTHON +

+
# read original image, in full color
+plant_seedling = iio.imread(uri="data/plant-seedling.jpg")
+
+# display the image
+fig, ax = plt.subplots()
+ax.imshow(plant_seedling)
+
+

We read the original image, now in full colour, and display it.

+

Next, we create the histogram, by calling the +np.histogram function three times, once for each of the +channels. We obtain the individual channels, by slicing the image along +the last axis. For example, we can obtain the red colour channel by +calling r_chan = image[:, :, 0].

+
+

PYTHON +

+
# tuple to select colors of each channel line
+colors = ("red", "green", "blue")
+
+# create the histogram plot, with three lines, one for
+# each color
+fig, ax = plt.subplots()
+ax.set_xlim([0, 256])
+for channel_id, color in enumerate(colors):
+    histogram, bin_edges = np.histogram(
+        plant_seedling[:, :, channel_id], bins=256, range=(0, 256)
+    )
+    ax.plot(bin_edges[0:-1], histogram, color=color)
+
+ax.set_title("Color Histogram")
+ax.set_xlabel("Color value")
+ax.set_ylabel("Pixel count")
+
+

We will draw the histogram line for each channel in a different +colour, and so we create a tuple of the colours to use for the three +lines with the

+

colors = ("red", "green", "blue")

+

line of code. Then, we limit the range of the x-axis with the +ax.set_xlim() function call.

+

Next, we use the for control structure to iterate +through the three channels, plotting an appropriately-coloured histogram +line for each. This may be new Python syntax for you, so we will take a +moment to discuss what is happening in the for +statement.

+

The Python built-in enumerate() function takes a list +and returns an iterator of tuples, where the first +element of the tuple is the index and the second element is the element +of the list.

+
+
+ +
+Callout +
+

Iterators, tuples, and +enumerate() +

+
+

In Python, an iterator, or an iterable object, is +something that can be iterated over with the for control +structure. A tuple is a sequence of objects, just like a list. +However, a tuple cannot be changed, and a tuple is indicated by +parentheses instead of square brackets. The enumerate() +function takes an iterable object, and returns an iterator of tuples +consisting of the 0-based index and the corresponding object.

+

For example, consider this small Python program:

+
+

PYTHON +

+
list = ("a", "b", "c", "d", "e")
+
+for x in enumerate(list):
+    print(x)
+
+

Executing this program would produce the following output:

+
+

OUTPUT +

+
(0, 'a')
+(1, 'b')
+(2, 'c')
+(3, 'd')
+(4, 'e')
+
+
+
+
+

In our colour histogram program, we are using a tuple, +(channel_id, color), as the for variable. The +first time through the loop, the channel_id variable takes +the value 0, referring to the position of the red colour +channel, and the color variable contains the string +"red". The second time through the loop the values are the +green channels index 1 and "green", and the +third time they are the blue channel index 2 and +"blue".

+

Inside the for loop, our code looks much like it did for +the grayscale example. We calculate the histogram for the current +channel with the

+

histogram, bin_edges = np.histogram(image[:, :, channel_id], bins=256, range=(0, 256))

+

function call, and then add a histogram line of the correct colour to +the plot with the

+

ax.plot(bin_edges[0:-1], histogram, color=color)

+

function call. Note the use of our loop variables, +channel_id and color.

+

Finally we label our axes and display the histogram, shown here:

+
Colour histogram
+
+ +
+Challenge +
+

Colour histogram with a mask (25 min)

+
+

We can also apply a mask to the images we apply the colour histogram +process to, in the same way we did for grayscale histograms. Consider +this image of a well plate, where various chemical sensors have been +applied to water and various concentrations of hydrochloric acid and +sodium hydroxide:

+
+

PYTHON +

+
# read the image
+wellplate = iio.imread(uri="data/wellplate-02.tif")
+
+# display the image
+fig, ax = plt.subplots()
+ax.imshow(wellplate)
+
+
Well plate image

Suppose we are interested in the colour histogram of one of the +sensors in the well plate image, specifically, the seventh well from the +left in the topmost row, which shows Erythrosin B reacting with +water.

+

Hover over the image with your mouse to find the centre of that well +and the radius (in pixels) of the well. Then create a circular mask to +select only the desired well. Then, use that mask to apply the colour +histogram operation to that well.

+

Your masked image should look like this:

+
Masked well plate

And, the program should produce a colour histogram that looks like +this:

+
Well plate histogram
+
+
+
+
+ +
+
+
+

PYTHON +

+
# create a circular mask to select the 7th well in the first row
+mask = np.zeros(shape=wellplate.shape[0:2], dtype="bool")
+circle = ski.draw.disk(center=(240, 1053), radius=49, shape=wellplate.shape[0:2])
+mask[circle] = 1
+
+# just for display:
+# make a copy of the image, call it masked_image, and
+# zero values where mask is False
+masked_img = np.array(wellplate)
+masked_img[~mask] = 0
+
+# create a new figure and display masked_img, to verify the
+# validity of your mask
+fig, ax = plt.subplots()
+ax.imshow(masked_img)
+
+# list to select colors of each channel line
+colors = ("red", "green", "blue")
+
+# create the histogram plot, with three lines, one for
+# each color
+fig, ax = plt.subplots()
+ax.set_xlim([0, 256])
+for (channel_id, color) in enumerate(colors):
+    # use your circular mask to apply the histogram
+    # operation to the 7th well of the first row
+    histogram, bin_edges = np.histogram(
+        wellplate[:, :, channel_id][mask], bins=256, range=(0, 256)
+    )
+
+    ax.plot(histogram, color=color)
+
+ax.set_xlabel("color value")
+ax.set_ylabel("pixel count")
+
+
+
+
+
+
+
+ +
+Key Points +
+
+
  • In many cases, we can load images in grayscale by passing the +mode="L" argument to the iio.imread() +function.
  • +
  • We can create histograms of images with the +np.histogram function.
  • +
  • We can display histograms using ax.plot() with the +bin_edges and histogram values returned by +np.histogram().
  • +
  • The plot can be customised using ax.set_xlabel(), +ax.set_ylabel(), ax.set_xlim(), +ax.set_ylim(), and ax.set_title().
  • +
  • We can separate the colour channels of an RGB image using slicing +operations and create histograms for each colour channel +separately.
  • +
+
+
+
+
+ + +
+
+ + + diff --git a/06-blurring.html b/06-blurring.html new file mode 100644 index 000000000..fbf67c8d2 --- /dev/null +++ b/06-blurring.html @@ -0,0 +1,949 @@ + +Image Processing with Python: Blurring Images +
+
+ + + + + +
+
+

Blurring Images

+

Last updated on 2026-03-20 | + + Edit this page

+ + + +
+ +
+ + + +
+

Overview

+
+
+
+
+

Questions

+
  • How can we apply a low-pass blurring filter to an image?
  • +
+
+
+
+
+
+

Objectives

+
  • Explain why applying a low-pass blurring filter to an image is +beneficial.
  • +
  • Apply a Gaussian blur filter to an image using scikit-image.
  • +
+
+
+
+
+

In this episode, we will learn how to use scikit-image functions to +blur images.

+

When processing an image, we are often interested in identifying +objects represented within it so that we can perform some further +analysis of these objects, e.g., by counting them, measuring their +sizes, etc. An important concept associated with the identification of +objects in an image is that of edges: the lines that represent +a transition from one group of similar pixels in the image to another +different group. One example of an edge is the pixels that represent the +boundaries of an object in an image, where the background of the image +ends and the object begins.

+

When we blur an image, we make the colour transition from one side of +an edge in the image to another smooth rather than sudden. The effect is +to average out rapid changes in pixel intensity. Blurring is a very +common operation we need to perform before other tasks such as thresholding. There are several +different blurring functions in the ski.filters module, so +we will focus on just one here, the Gaussian blur.

+
+
+ +
+Callout +
+

Filters

+
+

In the day-to-day, macroscopic world, we have physical filters which +separate out objects by size. A filter with small holes allows only +small objects through, leaving larger objects behind. This is a good +analogy for image filters. A high-pass filter will retain the smaller +details in an image, filtering out the larger ones. A low-pass filter +retains the larger features, analogous to what’s left behind by a +physical filter mesh. High- and low-pass, here, refer +to high and low spatial frequencies in the image. Details +associated with high spatial frequencies are small, a lot of these +features would fit across an image. Features associated with low spatial +frequencies are large - maybe a couple of big features per image.

+
+
+
+
+
+ +
+Callout +
+

Blurring

+
+

To blur is to make something less clear or distinct. This could be +interpreted quite broadly in the context of image analysis - anything +that reduces or distorts the detail of an image might apply. Applying a +low-pass filter, which removes detail occurring at high spatial +frequencies, is perceived as a blurring effect. A Gaussian blur is a +filter that makes use of a Gaussian kernel.

+
+
+
+
+
+ +
+Callout +
+

Kernels

+
+

A kernel can be used to implement a filter on an image. A kernel, in +this context, is a small matrix which is combined with the image using a +mathematical technique: convolution. Different sizes, shapes +and contents of kernel produce different effects. The kernel can be +thought of as a little image in itself, and will favour features of +similar size and shape in the main image. On convolution with an image, +a big, blobby kernel will retain big, blobby, low spatial frequency +features.

+
+
+
+

Gaussian blur

+

Consider this image of a cat, in particular the area of the image +outlined by the white square.

+
Cat image

Now, zoom in on the area of the cat’s eye, as shown in the left-hand +image below. When we apply a filter, we consider each pixel in the +image, one at a time. In this example, the pixel we are currently +working on is highlighted in red, as shown in the right-hand image.

+
Cat eye pixels

When we apply a filter, we consider rectangular groups of pixels +surrounding each pixel in the image, in turn. The kernel is +another group of pixels (a separate matrix / small image), of the same +dimensions as the rectangular group of pixels in the image, that moves +along with the pixel being worked on by the filter. The width and height +of the kernel must be an odd number, so that the pixel being worked on +is always in its centre. In the example shown above, the kernel is +square, with a dimension of seven pixels.

+

To apply the kernel to the current pixel, an average of the colour +values of the pixels surrounding it is calculated, weighted by the +values in the kernel. In a Gaussian blur, the pixels nearest the centre +of the kernel are given more weight than those far away from the centre. +The rate at which this weight diminishes is determined by a Gaussian +function, hence the name Gaussian blur.

+

A Gaussian function maps random variables into a normal distribution +or “Bell Curve”. Gaussian function

+ +

The shape of the function is described by a mean value μ, and a +variance value σ². The mean determines the central point of the bell +curve on the X axis, and the variance describes the spread of the +curve.

+

In fact, when using Gaussian functions in Gaussian blurring, we use a +2D Gaussian function to account for X and Y dimensions, but the same +rules apply. The mean μ is always 0, and represents the middle of the 2D +kernel. Increasing values of σ² in either dimension increases the amount +of blurring in that dimension.

+
2D Gaussian function
+

The averaging is done on a channel-by-channel basis, and the average +channel values become the new value for the pixel in the filtered image. +Larger kernels have more values factored into the average, and this +implies that a larger kernel will blur the image more than a smaller +kernel.

+

To get an idea of how this works, consider this plot of the +two-dimensional Gaussian function:

+
2D Gaussian function

Imagine that plot laid over the kernel for the Gaussian blur filter. +The height of the plot corresponds to the weight given to the underlying +pixel in the kernel. I.e., the pixels close to the centre become more +important to the filtered pixel colour than the pixels close to the +outer limits of the kernel. The shape of the Gaussian function is +controlled via its standard deviation, or sigma. A large sigma value +results in a flatter shape, while a smaller sigma value results in a +more pronounced peak. The mathematics involved in the Gaussian blur +filter are not quite that simple, but this explanation gives you the +basic idea.

+

To illustrate the blurring process, consider the blue channel colour +values from the seven-by-seven region of the cat image above:

+
Image corner pixels

The filter is going to determine the new blue channel value for the +centre pixel – the one that currently has the value 86. The filter +calculates a weighted average of all the blue channel values in the +kernel giving higher weight to the pixels near the centre of the +kernel.

+
Image multiplication

This weighted average, the sum of the multiplications, becomes the +new value for the centre pixel (3, 3). The same process would be used to +determine the green and red channel values, and then the kernel would be +moved over to apply the filter to the next pixel in the image.

+ +
+
+ +
+Callout +
+

Image edges

+
+

Something different needs to happen for pixels near the outer limits +of the image, since the kernel for the filter may be partially off the +image. For example, what happens when the filter is applied to the +upper-left pixel of the image? Here are the blue channel pixel values +for the upper-left pixel of the cat image, again assuming a +seven-by-seven kernel:

+
+

OUTPUT +

+
  x   x   x   x   x   x   x
+  x   x   x   x   x   x   x
+  x   x   x   x   x   x   x
+  x   x   x   4   5   9   2
+  x   x   x   5   3   6   7
+  x   x   x   6   5   7   8
+  x   x   x   5   4   5   3
+
+

The upper-left pixel is the one with value 4. Since the pixel is at +the upper-left corner, there are no pixels underneath much of the +kernel; here, this is represented by x’s. So, what does the filter do in +that situation?

+

The default mode is to fill in the nearest pixel value from +the image. For each of the missing x’s the image value closest to the x +is used. If we fill in a few of the missing pixels, you will see how +this works:

+
+

OUTPUT +

+
  x   x   x   4   x   x   x
+  x   x   x   4   x   x   x
+  x   x   x   4   x   x   x
+  4   4   4   4   5   9   2
+  x   x   x   5   3   6   7
+  x   x   x   6   5   7   8
+  x   x   x   5   4   5   3
+
+

Another strategy to fill those missing values is to reflect +the pixels that are in the image to fill in for the pixels that are +missing from the kernel.

+
+

OUTPUT +

+
  x   x   x   5   x   x   x
+  x   x   x   6   x   x   x
+  x   x   x   5   x   x   x
+  2   9   5   4   5   9   2
+  x   x   x   5   3   6   7
+  x   x   x   6   5   7   8
+  x   x   x   5   4   5   3
+
+

A similar process would be used to fill in all of the other missing +pixels from the kernel. Other border modes are available; you +can learn more about them in the scikit-image +documentation.

+
+
+
+

Let’s consider a very simple image to see blurring in action. The +animation below shows how the blur kernel (large red square) moves along +the image on the left in order to calculate the corresponding values for +the blurred image (yellow central square) on the right. In this simple +case, the original image is single-channel, but blurring would work +likewise on a multi-channel image.

+
Blur demo animation

scikit-image has built-in functions to perform blurring for us, so we +do not have to perform all of these mathematical operations ourselves. +Let’s work through an example of blurring an image with the scikit-image +Gaussian blur function.

+

First, import the packages needed for this episode:

+
+

PYTHON +

+
import imageio.v3 as iio
+import ipympl
+import matplotlib.pyplot as plt
+import skimage as ski
+
+%matplotlib widget
+
+

Then, we load the image, and display it:

+
+

PYTHON +

+
image = iio.imread(uri="data/gaussian-original.png")
+
+# display the image
+fig, ax = plt.subplots()
+ax.imshow(image)
+
+
Original image

Next, we apply the gaussian blur:

+
+

PYTHON +

+
sigma = 3.0
+
+# apply Gaussian blur, creating a new image
+blurred = ski.filters.gaussian(
+    image, sigma=(sigma, sigma), truncate=3.5, channel_axis=-1)
+
+

The first two arguments to ski.filters.gaussian() are +the image to blur, image, and a tuple defining the sigma to +use in ry- and cx-direction, (sigma, sigma). The third +parameter truncate is meant to pass the radius of the +kernel in number of sigmas. A Gaussian function is defined from +-infinity to +infinity, but our kernel (which must have a finite, +smaller size) can only approximate the real function. Therefore, we must +choose a certain distance from the centre of the function where we stop +this approximation, and set the final size of our kernel. In the above +example, we set truncate to 3.5, which means the kernel +size will be 2 * sigma * 3.5. For example, for a sigma of +1.0 the resulting kernel size would be 7, while for a sigma +of 2.0 the kernel size would be 14. The default value for +truncate in scikit-image is 4.0.

+

The last argument we passed to ski.filters.gaussian() is +used to specify the dimension which contains the (colour) channels. +Here, it is the last dimension; recall that, in Python, the +-1 index refers to the last position. In this case, the +last dimension is the third dimension (index 2), since our +image has three dimensions:

+
+

PYTHON +

+
print(image.ndim)
+
+
+

OUTPUT +

+
3
+
+

Finally, we display the blurred image:

+
+

PYTHON +

+
# display blurred image
+fig, ax = plt.subplots()
+ax.imshow(blurred)
+
+
Blurred image

Visualising Blurring

+

Somebody said once “an image is worth a thousand words”. What is +actually happening to the image pixels when we apply blurring may be +difficult to grasp. Let’s now visualise the effects of blurring from a +different perspective.

+

Let’s use the petri-dish image from previous episodes:

+
Bacteria colony
Graysacle version of the Petri dish image
+

What we want to see here is the pixel intensities from a lateral +perspective: we want to see the profile of intensities. For instance, +let’s look for the intensities of the pixels along the horizontal line +at Y=150:

+
+

PYTHON +

+
# read colonies color image and convert to grayscale
+image = iio.imread('data/colonies-01.tif')
+image_gray = ski.color.rgb2gray(image)
+
+# define the pixels for which we want to view the intensity (profile)
+xmin, xmax = (0, image_gray.shape[1])
+Y = ymin = ymax = 150
+
+# view the image indicating the profile pixels position
+fig, ax = plt.subplots()
+ax.imshow(image_gray, cmap='gray')
+ax.plot([xmin, xmax], [ymin, ymax], color='red')
+
+
Bacteria colony image with selected pixels marker
Grayscale Petri dish image marking selected +pixels for profiling
+

The intensity of those pixels we can see with a simple line plot:

+
+

PYTHON +

+
# select the vector of pixels along "Y"
+image_gray_pixels_slice = image_gray[Y, :]
+
+# guarantee the intensity values are in the [0:255] range (unsigned integers)
+image_gray_pixels_slice = ski.img_as_ubyte(image_gray_pixels_slice)
+
+fig, ax = plt.subplots()
+ax.plot(image_gray_pixels_slice, color='red')
+ax.set_ylim(255, 0)
+ax.set_ylabel('L')
+ax.set_xlabel('X')
+
+
Pixel intensities profile in original image
Intensities profile line plot of pixels along +Y=150 in original image
+

And now, how does the same set of pixels look in the corresponding +blurred image:

+
+

PYTHON +

+
# first, create a blurred version of (grayscale) image
+image_blur = ski.filters.gaussian(image_gray, sigma=3)
+
+# like before, plot the pixels profile along "Y"
+image_blur_pixels_slice = image_blur[Y, :]
+image_blur_pixels_slice = ski.img_as_ubyte(image_blur_pixels_slice)
+
+fig, ax = plt.subplots()
+ax.plot(image_blur_pixels_slice, 'red')
+ax.set_ylim(255, 0)
+ax.set_ylabel('L')
+ax.set_xlabel('X')
+
+
Pixel intensities profile in blurred image
Intensities profile of pixels along Y=150 in +blurred image
+

And that is why blurring is also called smoothing. +This is how low-pass filters affect neighbouring pixels.

+

Now that we have seen the effects of blurring an image from two +different perspectives, front and lateral, let’s take yet another look +using a 3D visualisation.

+
+
+ +
+Callout +
+

3D Plots with matplotlib

+
+

The code to generate these 3D plots is outside the scope of this +lesson but can be viewed by following the links in the captions.

+
+
+
+
3D surface plot showing pixel intensities across the whole example Petri dish image before blurring
A 3D plot of pixel intensities across the whole +Petri dish image before blurring. Explore +how this plot was created with matplotlib. Image credit: Carlos H Brandt.
+
3D surface plot illustrating the smoothing effect on pixel intensities across the whole example Petri dish image after blurring
A 3D plot of pixel intensities after Gaussian +blurring of the Petri dish image. Note the ‘smoothing’ effect on the +pixel intensities of the colonies in the image, and the ‘flattening’ of +the background noise at relatively low pixel intensities throughout the +image. Explore +how this plot was created with matplotlib. Image credit: Carlos H Brandt.
+
+
+ +
+Challenge +
+

Experimenting with sigma values (10 min)

+
+

The size and shape of the kernel used to blur an image can have a +significant effect on the result of the blurring and any downstream +analysis carried out on the blurred image. The next two exercises ask +you to experiment with the sigma values of the kernel, which is a good +way to develop your understanding of how the choice of kernel can +influence the result of blurring.

+

First, try running the code above with a range of smaller and larger +sigma values. Generally speaking, what effect does the sigma value have +on the blurred image?

+
+
+
+
+
+ +
+
+

Generally speaking, the larger the sigma value, the more blurry the +result. A larger sigma will tend to get rid of more noise in the image, +which will help for other operations we will cover soon, such as +thresholding. However, a larger sigma also tends to eliminate some of +the detail from the image. So, we must strike a balance with the sigma +value used for blur filters.

+
+
+
+
+
+
+ +
+Challenge +
+

Experimenting with kernel shape (10 min - +optional, not included in timing)

+
+

Now, what is the effect of applying an asymmetric kernel to blurring +an image? Try running the code above with different sigmas in the ry and +cx direction. For example, a sigma of 1.0 in the ry direction, and 6.0 +in the cx direction.

+
+
+
+
+
+ +
+
+
+

PYTHON +

+
# apply Gaussian blur, with a sigma of 1.0 in the ry direction, and 6.0 in the cx direction
+blurred = ski.filters.gaussian(
+    image, sigma=(1.0, 6.0), truncate=3.5, channel_axis=-1
+)
+
+# display blurred image
+fig, ax = plt.subplots()
+ax.imshow(blurred)
+
+
Rectangular kernel blurred image

These unequal sigma values produce a kernel that is rectangular +instead of square. The result is an image that is much more blurred in +the X direction than in the Y direction. For most use cases, a uniform +blurring effect is desirable and this kind of asymmetric blurring should +be avoided. However, it can be helpful in specific circumstances, e.g., +when noise is present in your image in a particular pattern or +orientation, such as vertical lines, or when you want to remove +uniform noise without blurring edges present in the image in a +particular orientation.

+
+
+
+
+

Other methods of blurring

+

The Gaussian blur is a way to apply a low-pass filter in +scikit-image. It is often used to remove Gaussian (i.e., random) noise +in an image. For other kinds of noise, e.g., “salt and pepper”, a median +filter is typically used. See the +skimage.filters documentation for a list of available +filters.

+
+
+ +
+Key Points +
+
+
  • Applying a low-pass blurring filter smooths edges and removes noise +from an image.
  • +
  • Blurring is often used as a first step before we perform +thresholding or edge detection.
  • +
  • The Gaussian blur can be applied to an image with the +ski.filters.gaussian() function.
  • +
  • Larger sigma values may remove more noise, but they will also remove +detail from an image.
  • +
+
+
+
+
+ + +
+
+ + + diff --git a/07-thresholding.html b/07-thresholding.html new file mode 100644 index 000000000..0b9ea95f6 --- /dev/null +++ b/07-thresholding.html @@ -0,0 +1,1243 @@ + +Image Processing with Python: Thresholding +
+
+ + + + + +
+
+

Thresholding

+

Last updated on 2026-04-28 | + + Edit this page

+ + + +
+ +
+ + + +
+

Overview

+
+
+
+
+

Questions

+
  • How can we use thresholding to produce a binary image?
  • +
+
+
+
+
+
+

Objectives

+
  • Explain what thresholding is and how it can be used.
  • +
  • Use histograms to determine appropriate threshold values to use for +the thresholding process.
  • +
  • Apply simple, fixed-level binary thresholding to an image.
  • +
  • Explain the difference between using the operator > +or the operator < to threshold an image represented by a +NumPy array.
  • +
  • Describe the shape of a binary image produced by thresholding via +> or <.
  • +
  • Explain when Otsu’s method for automatic thresholding is +appropriate.
  • +
  • Apply automatic thresholding to an image using Otsu’s method.
  • +
  • Use the np.count_nonzero() function to count the number +of non-zero pixels in an image.
  • +
+
+
+
+
+

In this episode, we will learn how to use scikit-image functions to +apply thresholding to an image. Thresholding is a type of image +segmentation, where an image is split into different regions, or +segments. These segments can then be analyzed separately.

+

In thresholding, we convert an image from colour or grayscale into a +binary image, i.e., one that is simply black and white. Most +frequently, we use thresholding as a way to select areas of interest of +an image, while ignoring the parts we are not concerned with. We have +already done some simple thresholding, in the “Manipulating pixels” +section of the Working with +scikit-image episode. In that case, we used a simple NumPy +array manipulation to separate the pixels belonging to the root system +of a plant from the black background. In this episode, we will learn how +to use scikit-image functions to perform thresholding. Then, we will use +the masks returned by these functions to select the parts of an image we +are interested in.

+

First, import the packages needed for this episode

+
+

PYTHON +

+
import glob
+
+import imageio.v3 as iio
+import ipympl
+import matplotlib.pyplot as plt
+import numpy as np
+import skimage as ski
+
+%matplotlib widget
+
+

Simple thresholding

+

Consider the image data/shapes-01.jpg with a series of +crudely cut shapes set against a white background.

+
+

PYTHON +

+
# load the image
+shapes01 = iio.imread(uri="data/shapes-01.jpg")
+
+fig, ax = plt.subplots()
+ax.imshow(shapes01)
+
+
Image with geometric shapes on white background

Now suppose we want to select only the shapes from the image. In +other words, we want to leave the pixels belonging to the shapes “on,” +while turning the rest of the pixels “off,” by setting their colour +channel values to zeros. The scikit-image library has several different +methods of thresholding. We will start with the simplest version, which +involves an important step of human input. Specifically, in this simple, +fixed-level thresholding, we have to provide a threshold value +t.

+

The process works like this. First, we will load the original image, +convert it to grayscale, and de-noise it as in the Blurring Images episode.

+
+

PYTHON +

+
# convert the image to grayscale
+gray_shapes = ski.color.rgb2gray(shapes01)
+
+# blur the image to denoise
+blurred_shapes = ski.filters.gaussian(gray_shapes, sigma=1.0)
+
+fig, ax = plt.subplots()
+ax.imshow(blurred_shapes, cmap="gray")
+
+
Grayscale image of the geometric shapes

Next, we would like to apply the threshold t such that +pixels with grayscale values on one side of t will be +turned “on”, while pixels with grayscale values on the other side will +be turned “off”. How might we do that? Remember that grayscale images +contain pixel values in the range from 0 to 1, so we are looking for a +threshold t in the closed range [0.0, 1.0]. We see in the +image that the geometric shapes are “darker” than the white background +but there is also some light gray noise on the background. One way to +determine a “good” value for t is to look at the grayscale +histogram of the image and try to identify what grayscale ranges +correspond to the shapes in the image or the background.

+

The histogram for the shapes image shown above can be produced as in +the Creating Histograms +episode.

+
+

PYTHON +

+
# create a histogram of the blurred grayscale image
+histogram, bin_edges = np.histogram(blurred_shapes, bins=256, range=(0.0, 1.0))
+
+fig, ax = plt.subplots()
+ax.plot(bin_edges[0:-1], histogram)
+ax.set_title("Grayscale Histogram")
+ax.set_xlabel("grayscale value")
+ax.set_ylabel("pixels")
+ax.set_xlim(0, 1.0)
+
+
Grayscale histogram of the geometric shapes image

Since the image has a white background, most of the pixels in the +image are white. This corresponds nicely to what we see in the +histogram: there is a peak near the value of 1.0. If we want to select +the shapes and not the background, we want to turn off the white +background pixels, while leaving the pixels for the shapes turned on. +So, we should choose a value of t somewhere before the +large peak and turn pixels above that value “off”. Let us choose +t=0.8.

+

To apply the threshold t, we can use the NumPy +comparison operators to create a mask. Here, we want to turn “on” all +pixels which have values smaller than the threshold, so we use the less +operator < to compare the blurred_image to +the threshold t. The operator returns a mask, that we +capture in the variable binary_mask. It has only one +channel, and each of its values is either 0 or 1. The binary mask +created by the thresholding operation can be shown with +ax.imshow, where the False entries are shown +as black pixels (0-valued) and the True entries are shown +as white pixels (1-valued).

+
+

PYTHON +

+
# create a mask based on the threshold
+t = 0.8
+binary_mask = blurred_shapes < t
+
+fig, ax = plt.subplots()
+ax.imshow(binary_mask, cmap="gray")
+
+
Binary mask of the geometric shapes created by thresholding

You can see that the areas where the shapes were in the original area +are now white, while the rest of the mask image is black.

+
+
+ +
+Callout +
+

What makes a good threshold?

+
+

As is often the case, the answer to this question is “it depends”. In +the example above, we could have just switched off all the white +background pixels by choosing t=1.0, but this would leave +us with some background noise in the mask image. On the other hand, if +we choose too low a value for the threshold, we could lose some of the +shapes that are too bright. You can experiment with the threshold by +re-running the above code lines with different values for +t. In practice, it is a matter of domain knowledge and +experience to interpret the peaks in the histogram so to determine an +appropriate threshold. The process often involves trial and error, which +is a drawback of the simple thresholding method. Below we will introduce +automatic thresholding, which uses a quantitative, mathematical +definition for a good threshold that allows us to determine the value of +t automatically. It is worth noting that the principle for +simple and automatic thresholding can also be used for images with pixel +ranges other than [0.0, 1.0]. For example, we could perform thresholding +on pixel intensity values in the range [0, 255] as we have already seen +in the Working with +scikit-image episode.

+
+
+
+

We can now apply the binary_mask to the original +coloured image as we have learned in the +Drawing and Bitwise Operations episode. What we are left +with is only the coloured shapes from the original.

+
+

PYTHON +

+
# use the binary_mask to select the "interesting" part of the image
+selection = shapes01.copy()
+selection[~binary_mask] = 0
+
+fig, ax = plt.subplots()
+ax.imshow(selection)
+
+
Selected shapes after applying binary mask
+
+ +
+Challenge +
+

More practice with simple thresholding (15 +min)

+
+

Now, it is your turn to practice. Suppose we want to use simple +thresholding to select only the coloured shapes (in this particular case +we consider grayish to be a colour, too) from the image +data/shapes-02.jpg:

+
Another image with geometric shapes on white background

First, plot the grayscale histogram as in the Creating Histogram episode and +examine the distribution of grayscale values in the image. What do you +think would be a good value for the threshold t?

+
+
+
+
+
+ +
+
+

The histogram for the data/shapes-02.jpg image can be +shown with

+
+

PYTHON +

+
shapes = iio.imread(uri="data/shapes-02.jpg")
+gray_shapes = ski.color.rgb2gray(shapes)
+histogram, bin_edges = np.histogram(gray_shapes, bins=256, range=(0.0, 1.0))
+
+fig, ax = plt.subplots()
+ax.plot(bin_edges[0:-1], histogram)
+ax.set_title("Graylevel histogram")
+ax.set_xlabel("gray value")
+ax.set_ylabel("pixel count")
+ax.set_xlim(0, 1.0)
+
+
Grayscale histogram of the second geometric shapes image

We can see a large spike around 0.3, and a smaller spike around 0.7. +The spike near 0.3 represents the darker background, so it seems like a +value close to t=0.5 would be a good choice.

+
+
+
+
+
+
+ +
+Challenge +
+

More practice with simple thresholding (15 +min) (continued) +

+
+

Next, create a mask to turn the pixels above the threshold +t on and pixels below the threshold t off. +Note that unlike the image with a white background we used above, here +the peak for the background colour is at a lower gray level than the +shapes. Therefore, change the comparison operator less < +to greater > to create the appropriate mask. Then apply +the mask to the image and view the thresholded image. If everything +works as it should, your output should show only the coloured shapes on +a black background.

+
+
+
+
+
+ +
+
+

Here are the commands to create and view the binary mask

+
+

PYTHON +

+
t = 0.5
+binary_mask = gray_shapes > t
+
+fig, ax = plt.subplots()
+ax.imshow(binary_mask, cmap="gray")
+
+
Binary mask created by thresholding the second geometric shapes image

And here are the commands to apply the mask and view the thresholded +image

+
+

PYTHON +

+
shapes02 = iio.imread(uri="data/shapes-02.jpg")
+selection = shapes02.copy()
+selection[~binary_mask] = 0
+
+fig, ax = plt.subplots()
+ax.imshow(selection)
+
+
Selected shapes after applying binary mask to the second geometric shapes image
+
+
+
+

Automatic thresholding

+

The downside of the simple thresholding technique is that we have to +make an educated guess about the threshold t by inspecting +the histogram. There are also automatic thresholding methods +that can determine the threshold automatically for us. One such method +is Otsu’s +method. It is particularly useful for situations where the +grayscale histogram of an image has two peaks that correspond to +background and objects of interest.

+
+
+ +
+Callout +
+

Denoising an image before thresholding

+
+

In practice, it is often necessary to denoise the image before +thresholding, which can be done with one of the methods from the Blurring Images episode.

+
+
+
+

Consider the image data/maize-root-cluster.jpg of a +maize root system which we have seen before in the Working with scikit-image +episode.

+
+

PYTHON +

+
maize_roots = iio.imread(uri="data/maize-root-cluster.jpg")
+
+fig, ax = plt.subplots()
+ax.imshow(maize_roots)
+
+
Image of a maize root

We use Gaussian blur with a sigma of 1.0 to denoise the root image. +Let us look at the grayscale histogram of the denoised image.

+
+

PYTHON +

+
# convert the image to grayscale
+gray_image = ski.color.rgb2gray(maize_roots)
+
+# blur the image to denoise
+blurred_image = ski.filters.gaussian(gray_image, sigma=1.0)
+
+# show the histogram of the blurred image
+histogram, bin_edges = np.histogram(blurred_image, bins=256, range=(0.0, 1.0))
+fig, ax = plt.subplots()
+ax.plot(bin_edges[0:-1], histogram)
+ax.set_title("Graylevel histogram")
+ax.set_xlabel("gray value")
+ax.set_ylabel("pixel count")
+ax.set_xlim(0, 1.0)
+
+
Grayscale histogram of the maize root image

The histogram has a significant peak around 0.2 and then a broader +“hill” around 0.6 followed by a smaller peak near 1.0. Looking at the +grayscale image, we can identify the peak at 0.2 with the background and +the broader peak with the foreground. Thus, this image is a good +candidate for thresholding with Otsu’s method. The mathematical details +of how this works are complicated (see the +scikit-image documentation if you are interested), but the outcome +is that Otsu’s method finds a threshold value between the two peaks of a +grayscale histogram which might correspond well to the foreground and +background depending on the data and application.

+ +

The ski.filters.threshold_otsu() function can be used to +determine the threshold automatically via Otsu’s method. Then NumPy +comparison operators can be used to apply it as before. Here are the +Python commands to determine the threshold t with Otsu’s +method.

+
+

PYTHON +

+
# perform automatic thresholding
+t = ski.filters.threshold_otsu(blurred_image)
+print("Found automatic threshold t = {}.".format(t))
+
+
+

OUTPUT +

+
Found automatic threshold t = 0.4116003928683858.
+
+

For this root image and a Gaussian blur with the chosen sigma of 1.0, +the computed threshold value is 0.42. No we can create a binary mask +with the comparison operator >. As we have seen before, +pixels above the threshold value will be turned on, those below the +threshold will be turned off.

+
+

PYTHON +

+
# create a binary mask with the threshold found by Otsu's method
+binary_mask = blurred_image > t
+
+fig, ax = plt.subplots()
+ax.imshow(binary_mask, cmap="gray")
+
+
Binary mask of the maize root system

Finally, we use the mask to select the foreground:

+
+

PYTHON +

+
# apply the binary mask to select the foreground
+selection = maize_roots.copy()
+selection[~binary_mask] = 0
+
+fig, ax = plt.subplots()
+ax.imshow(selection)
+
+
Masked selection of the maize root system

Application: measuring root mass

+

Let us now turn to an application where we can apply thresholding and +other techniques we have learned to this point. Consider these four +maize root system images, which you can find in the files +data/trial-016.jpg, data/trial-020.jpg, +data/trial-216.jpg, and +data/trial-293.jpg.

+
Four images of maize roots

Suppose we are interested in the amount of plant material in each +image, and in particular how that amount changes from image to image. +Perhaps the images represent the growth of the plant over time, or +perhaps the images show four different maize varieties at the same phase +of their growth. The question we would like to answer is, “how much root +mass is in each image?”

+

We will first construct a Python program to measure this value for a +single image. Our strategy will be this:

+
  1. Read the image, converting it to grayscale as it is read. For this +application we do not need the colour image.
  2. +
  3. Blur the image.
  4. +
  5. Use Otsu’s method of thresholding to create a binary image, where +the pixels that were part of the maize plant are white, and everything +else is black.
  6. +
  7. Save the binary image so it can be examined later.
  8. +
  9. Count the white pixels in the binary image, and divide by the number +of pixels in the image. This ratio will be a measure of the root mass of +the plant in the image.
  10. +
  11. Output the name of the image processed and the root mass ratio.
  12. +

Our intent is to perform these steps and produce the numeric result - +a measure of the root mass in the image - without human intervention. +Implementing the steps within a Python function will enable us to call +this function for different images.

+

Here is a Python function that implements this root-mass-measuring +strategy. Since the function is intended to produce numeric output +without human interaction, it does not display any of the images. Almost +all of the commands should be familiar, and in fact, it may seem simpler +than the code we have worked on thus far, because we are not displaying +any of the images.

+
+

PYTHON +

+
def measure_root_mass(filename, sigma=1.0):
+
+    # read the original image, converting to grayscale on the fly
+    image = iio.imread(uri=filename, mode="L")
+
+    # blur before thresholding
+    blurred_image = ski.filters.gaussian(image, sigma=sigma)
+
+    # perform automatic thresholding to produce a binary image
+    t = ski.filters.threshold_otsu(blurred_image)
+    binary_mask = blurred_image > t
+
+    # determine root mass ratio
+    root_pixels = np.count_nonzero(binary_mask)
+    density = root_pixels / binary_mask.size
+
+    return density
+
+

The function begins with reading the original image from the file +filename. We use iio.imread() with the +optional argument mode="L" to automatically convert it to +grayscale. Next, the grayscale image is blurred with a Gaussian filter +with the value of sigma that is passed to the function. +Then we determine the threshold t with Otsu’s method and +create a binary mask just as we did in the previous section. Up to this +point, everything should be familiar.

+

The final part of the function determines the root mass ratio in the +image. Recall that in the binary_mask, every pixel has +either a value of zero (black/background) or one (white/foreground). We +want to count the number of white pixels, which can be accomplished with +a call to the NumPy function np.count_nonzero. Finally, the +density ratio is calculated by dividing the number of white pixels by +the total number of pixels binary_mask.size in the image. +The function returns then root density of the image.

+

We can call this function with any filename and provide a sigma value +for the blurring. If no sigma value is provided, the default value 1.0 +will be used. For example, for the file data/trial-016.jpg +and a sigma value of 1.5, we would call the function like this:

+
+

PYTHON +

+
measure_root_mass(filename="data/trial-016.jpg", sigma=1.5)
+
+
+

OUTPUT +

+
0.04907247340425532
+
+

Now we can use the function to process the series of four images +shown above. In a real-world scientific situation, there might be +dozens, hundreds, or even thousands of images to process. To save us the +tedium of calling the function for each image by hand, we can write a +loop that processes all files automatically. The following code block +assumes that the files are located in the same directory and the +filenames all start with the trial- prefix and end with +the .jpg suffix.

+
+

PYTHON +

+
all_files = sorted(glob.glob("data/trial-*.jpg"))
+for filename in all_files:
+    density = measure_root_mass(filename=filename, sigma=1.5)
+    # output in format suitable for .csv
+    print(filename, density, sep=",")
+
+
+

OUTPUT +

+
data/trial-016.jpg,0.04907247340425532
+data/trial-020.jpg,0.06381366356382978
+data/trial-216.jpg,0.14205152925531914
+data/trial-293.jpg,0.13665791223404256
+
+
+
+ +
+Callout +
+
+

Compare your results with the values above. Do they match exactly? +You may find that certain decimal values differ slightly, even when +using identical input parameters.

+

This variation often stems from the specific versions of your +installed packages (such as numpy or +scikit-image). As these libraries evolve, updates can +introduce subtle changes in numerical handling, underlying algorithms, +or rounding logic. This highlights why reproducible environments, as +well as reproducible code, are essential for consistent scientific +computing.

+
+
+
+
+
+ +
+Challenge +
+

Ignoring more of the images – brainstorming +(10 min)

+
+

Let us take a closer look at the binary masks produced by the +measure_root_mass function.

+
Binary masks of the four maize root images

You may have noticed in the section on automatic thresholding that +the thresholded image does include regions of the image aside of the +plant root: the numbered labels and the white circles in each image are +preserved during the thresholding, because their grayscale values are +above the threshold. Therefore, our calculated root mass ratios include +the white pixels of the label and white circle that are not part of the +plant root. Those extra pixels affect how accurate the root mass +calculation is!

+

How might we remove the labels and circles before calculating the +ratio, so that our results are more accurate? Think about some options +given what we have learned so far.

+
+
+
+
+
+ +
+
+

One approach we might take is to try to completely mask out a region +from each image, particularly, the area containing the white circle and +the numbered label. If we had coordinates for a rectangular area on the +image that contained the circle and the label, we could mask the area +out by using techniques we learned in the +Drawing and Bitwise Operations episode.

+

However, a closer inspection of the binary images raises some issues +with that approach. Since the roots are not always constrained to a +certain area in the image, and since the circles and labels are in +different locations each time, we would have difficulties coming up with +a single rectangle that would work for every image. We could +create a different masking rectangle for each image, but that is not a +practicable approach if we have hundreds or thousands of images to +process.

+

Another approach we could take is to apply two thresholding steps to +the image. Look at the graylevel histogram of the file +data/trial-016.jpg shown above again: Notice the peak near +1.0? Recall that a grayscale value of 1.0 corresponds to white pixels: +the peak corresponds to the white label and circle. So, we could use +simple binary thresholding to mask the white circle and label from the +image, and then we could use Otsu’s method to select the pixels in the +plant portion of the image.

+

Note that most of this extra work in processing the image could have +been avoided during the experimental design stage, with some careful +consideration of how the resulting images would be used. For example, +all of the following measures could have made the images easier to +process, by helping us predict and/or detect where the label is in the +image and subsequently mask it from further processing:

+
  • Using labels with a consistent size and shape
  • +
  • Placing all the labels in the same position, relative to the +sample
  • +
  • Using a non-white label, with non-black writing
  • +
+
+
+
+
+
+ +
+Challenge +
+

Ignoring more of the images – implementation +(30 min - optional, not included in timing)

+
+

Implement an enhanced version of the function +measure_root_mass that applies simple binary thresholding +to remove the white circle and label from the image before applying +Otsu’s method.

+
+
+
+
+
+ +
+
+

We can apply a simple binary thresholding with a threshold +t=0.95 to remove the label and circle from the image. We +can then use the binary mask to calculate the Otsu threshold without the +pixels from the label and circle.

+
+

PYTHON +

+
def enhanced_root_mass(filename, sigma):
+
+    # read the original image, converting to grayscale on the fly
+    image = iio.imread(uri=filename, mode="L")
+
+    # blur before thresholding
+    blurred_image = ski.filters.gaussian(image, sigma=sigma)
+
+    # perform binary thresholding to mask the white label and circle
+    binary_mask = blurred_image < 0.95
+
+    # perform automatic thresholding using only the pixels with value True in the binary mask
+    t = ski.filters.threshold_otsu(blurred_image[binary_mask])
+
+    # update binary mask to identify pixels which are both less than 0.95 and greater than t
+    binary_mask = (blurred_image < 0.95) & (blurred_image > t)
+
+    # determine root mass ratio
+    root_pixels = np.count_nonzero(binary_mask)
+    density = root_pixels / binary_mask.size
+
+    return density
+
+
+all_files = sorted(glob.glob("data/trial-*.jpg"))
+for filename in all_files:
+    density = enhanced_root_mass(filename=filename, sigma=1.5)
+    # output in format suitable for .csv
+    print(filename, density, sep=",")
+
+

The output of the improved program does illustrate that the white +circles and labels were skewing our root mass ratios:

+
+

OUTPUT +

+
data/trial-016.jpg,0.046261136968085106
+data/trial-020.jpg,0.05887167553191489
+data/trial-216.jpg,0.13712067819148935
+data/trial-293.jpg,0.1319044215425532
+
+
+
+ +
+
+

The & operator above means that we have defined a +logical AND statement. This combines the two tests of pixel intensities +in the blurred image such that both must be true for a pixel’s position +to be set to True in the resulting mask.

+ + + + + + + + + + + + +
Result of t < blurred_image +Result of blurred_image < 0.95 +Resulting value in binary_mask +
FalseTrueFalse
TrueFalseFalse
TrueTrueTrue

Knowing how to construct this kind of logical operation can be very +helpful in image processing. The University of Minnesota Library’s guide to Boolean +operators is a good place to start if you want to learn more.

+
+
+
+
+

Here are the binary images produced by the additional thresholding. +Note that we have not completely removed the offending white pixels. +Outlines still remain. However, we have reduced the number of extraneous +pixels, which should make the output more accurate.

+
Improved binary masks of the four maize root images
+
+
+
+
+
+ +
+Challenge +
+

Thresholding a bacteria colony image (15 +min)

+
+

In the images directory data/, you will find an image +named colonies-01.tif.

+
Image of bacteria colonies in a petri dish

This is one of the images you will be working with in the +morphometric challenge at the end of the workshop.

+
  1. Plot and inspect the grayscale histogram of the image to determine a +good threshold value for the image.
  2. +
  3. Create a binary mask that leaves the pixels in the bacteria colonies +“on” while turning the rest of the pixels in the image “off”.
  4. +
+
+
+
+
+ +
+
+

Here is the code to create the grayscale histogram:

+
+

PYTHON +

+
bacteria = iio.imread(uri="data/colonies-01.tif")
+gray_image = ski.color.rgb2gray(bacteria)
+blurred_image = ski.filters.gaussian(gray_image, sigma=1.0)
+histogram, bin_edges = np.histogram(blurred_image, bins=256, range=(0.0, 1.0))
+fig, ax = plt.subplots()
+ax.plot(bin_edges[0:-1], histogram)
+ax.set_title("Graylevel histogram")
+ax.set_xlabel("gray value")
+ax.set_ylabel("pixel count")
+ax.set_xlim(0, 1.0)
+
+
Grayscale histogram of the bacteria colonies image

The peak near one corresponds to the white image background, and the +broader peak around 0.5 corresponds to the yellow/brown culture medium +in the dish. The small peak near zero is what we are after: the dark +bacteria colonies. A reasonable choice thus might be to leave pixels +below t=0.2 on.

+

Here is the code to create and show the binarized image using the +< operator with a threshold t=0.2:

+
+

PYTHON +

+
t = 0.2
+binary_mask = blurred_image < t
+
+fig, ax = plt.subplots()
+ax.imshow(binary_mask, cmap="gray")
+
+
Binary mask of the bacteria colonies image

When you experiment with the threshold a bit, you can see that in +particular the size of the bacteria colony near the edge of the dish in +the top right is affected by the choice of the threshold.

+
+
+
+
+
+
+ +
+Key Points +
+
+
  • Thresholding produces a binary image, where all pixels with +intensities above (or below) a threshold value are turned on, while all +other pixels are turned off.
  • +
  • The binary images produced by thresholding are held in +two-dimensional NumPy arrays, since they have only one colour value +channel. They are boolean, hence they contain the values 0 (off) and 1 +(on).
  • +
  • Thresholding can be used to create masks that select only the +interesting parts of an image, or as the first step before edge +detection or finding contours.
  • +
+
+
+
+
+ + +
+
+ + + diff --git a/08-connected-components.html b/08-connected-components.html new file mode 100644 index 000000000..6c17a0226 --- /dev/null +++ b/08-connected-components.html @@ -0,0 +1,1318 @@ + +Image Processing with Python: Connected Component Analysis +
+
+ + + + + +
+
+

Connected Component Analysis

+

Last updated on 2026-03-20 | + + Edit this page

+ + + +
+ +
+ + + +
+

Overview

+
+
+
+
+

Questions

+
  • How to extract separate objects from an image and describe these +objects quantitatively.
  • +
+
+
+
+
+
+

Objectives

+
  • Understand the term object in the context of images.
  • +
  • Learn about pixel connectivity.
  • +
  • Learn how Connected Component Analysis (CCA) works.
  • +
  • Use CCA to produce an image that highlights every object in a +different colour.
  • +
  • Characterise each object with numbers that describe its +appearance.
  • +
+
+
+
+
+

Objects

+

In the Thresholding +episode we have covered dividing an image into foreground and +background pixels. In the shapes example image, we considered the +coloured shapes as foreground objects on a white +background.

+
Original shapes image

In thresholding we went from the original image to this version:

+
Mask created by thresholding

Here, we created a mask that only highlights the parts of the image +that we find interesting, the objects. All objects have pixel +value of True while the background pixels are +False.

+

By looking at the mask image, one can count the objects that are +present in the image (7). But how did we actually do that, how did we +decide which lump of pixels constitutes a single object?

+ +

Pixel Neighborhoods

+

In order to decide which pixels belong to the same object, one can +exploit their neighborhood: pixels that are directly next to each other +and belong to the foreground class can be considered to belong to the +same object.

+

Let’s discuss the concept of pixel neighborhoods in more detail. +Consider the following mask “image” with 8 rows, and 8 columns. For the +purpose of illustration, the digit 0 is used to represent +background pixels, and the letter X is used to represent +object pixels foreground).

+
+

OUTPUT +

+
0 0 0 0 0 0 0 0
+0 X X 0 0 0 0 0
+0 X X 0 0 0 0 0
+0 0 0 X X X 0 0
+0 0 0 X X X X 0
+0 0 0 0 0 0 0 0
+
+

The pixels are organised in a rectangular grid. In order to +understand pixel neighborhoods we will introduce the concept of “jumps” +between pixels. The jumps follow two rules: First rule is that one jump +is only allowed along the column, or the row. Diagonal jumps are not +allowed. So, from a centre pixel, denoted with o, only the +pixels indicated with a 1 are reachable:

+
+

OUTPUT +

+
- 1 -
+1 o 1
+- 1 -
+
+

The pixels on the diagonal (from o) are not reachable +with a single jump, which is denoted by the -. The pixels +reachable with a single jump form the 1-jump +neighborhood.

+

The second rule states that in a sequence of jumps, one may only jump +in row and column direction once -> they have to be +orthogonal. An example of a sequence of orthogonal jumps is +shown below. Starting from o the first jump goes along the +row to the right. The second jump then goes along the column direction +up. After this, the sequence cannot be continued as a jump has already +been made in both row and column direction.

+
+

OUTPUT +

+
- - 2
+- o 1
+- - -
+
+

All pixels reachable with one, or two jumps form the +2-jump neighborhood. The grid below illustrates the +pixels reachable from the centre pixel o with a single +jump, highlighted with a 1, and the pixels reachable with 2 +jumps with a 2.

+
+

OUTPUT +

+
2 1 2
+1 o 1
+2 1 2
+
+

We want to revisit our example image mask from above and apply the +two different neighborhood rules. With a single jump connectivity for +each pixel, we get two resulting objects, highlighted in the image with +A’s and B’s.

+
+

OUTPUT +

+
0 0 0 0 0 0 0 0
+0 A A 0 0 0 0 0
+0 A A 0 0 0 0 0
+0 0 0 B B B 0 0
+0 0 0 B B B B 0
+0 0 0 0 0 0 0 0
+
+

In the 1-jump version, only pixels that have direct neighbors along +rows or columns are considered connected. Diagonal connections are not +included in the 1-jump neighborhood. With two jumps, however, we only +get a single object A because pixels are also considered +connected along the diagonals.

+
+

OUTPUT +

+
0 0 0 0 0 0 0 0
+0 A A 0 0 0 0 0
+0 A A 0 0 0 0 0
+0 0 0 A A A 0 0
+0 0 0 A A A A 0
+0 0 0 0 0 0 0 0
+
+
+
+ +
+Challenge +
+

Object counting (optional, not included in +timing)

+
+

How many objects with 1 orthogonal jump, how many with 2 orthogonal +jumps?

+
+

OUTPUT +

+
0 0 0 0 0 0 0 0
+0 X 0 0 0 X X 0
+0 0 X 0 0 0 0 0
+0 X 0 X X X 0 0
+0 X 0 X X 0 0 0
+0 0 0 0 0 0 0 0
+
+

1 jump

+
  1. 1
  2. +
  3. 5
  4. +
  5. 2
  6. +
+
+
+
+
+ +
+
+
  1. 5
  2. +
+
+
+
+
+
+ +
+Challenge +
+

Object counting (optional, not included in +timing) (continued) +

+
+

2 jumps

+
  1. 2
  2. +
  3. 3
  4. +
  5. 5
  6. +
+
+
+
+
+ +
+
+
  1. 2
  2. +
+
+
+
+
+
+ +
+Callout +
+

Jumps and neighborhoods

+
+

We have just introduced how you can reach different neighboring +pixels by performing one or more orthogonal jumps. We have used the +terms 1-jump and 2-jump neighborhood. There is also a different way of +referring to these neighborhoods: the 4- and 8-neighborhood. With a +single jump you can reach four pixels from a given starting pixel. +Hence, the 1-jump neighborhood corresponds to the 4-neighborhood. When +two orthogonal jumps are allowed, eight pixels can be reached, so the +2-jump neighborhood corresponds to the 8-neighborhood.

+
+
+
+

Connected Component Analysis

+

In order to find the objects in an image, we want to employ an +operation that is called Connected Component Analysis (CCA). This +operation takes a binary image as an input. Usually, the +False value in this image is associated with background +pixels, and the True value indicates foreground, or object +pixels. Such an image can be produced, e.g., with thresholding. Given a +thresholded image, the connected component analysis produces a new +labeled image with integer pixel values. Pixels with the same +value, belong to the same object. scikit-image provides connected +component analysis in the function ski.measure.label(). Let +us add this function to the already familiar steps of thresholding an +image.

+

First, import the packages needed for this episode:

+
+

PYTHON +

+
import imageio.v3 as iio
+import ipympl
+import matplotlib.pyplot as plt
+import numpy as np
+import skimage as ski
+
+%matplotlib widget
+
+

In this episode, we will use the ski.measure.label +function to perform the CCA.

+

Next, we define a reusable Python function +connected_components:

+
+

PYTHON +

+
def connected_components(filename, sigma=1.0, t=0.5, connectivity=2):
+    # load the image
+    image = iio.imread(filename)
+    # convert the image to grayscale
+    gray_image = ski.color.rgb2gray(image)
+    # denoise the image with a Gaussian filter
+    blurred_image = ski.filters.gaussian(gray_image, sigma=sigma)
+    # mask the image according to threshold
+    binary_mask = blurred_image < t
+    # perform connected component analysis
+    labeled_image, count = ski.measure.label(binary_mask,
+                                                 connectivity=connectivity, return_num=True)
+    return labeled_image, count
+
+

The first four lines of code are familiar from the Thresholding episode.

+ +

Then we call the ski.measure.label function. This +function has one positional argument where we pass the +binary_mask, i.e., the binary image to work on. With the +optional argument connectivity, we specify the neighborhood +in units of orthogonal jumps. For example, by setting +connectivity=2 we will consider the 2-jump neighborhood +introduced above. The function returns a labeled_image +where each pixel has a unique value corresponding to the object it +belongs to. In addition, we pass the optional parameter +return_num=True to return the maximum label index as +count.

+
+
+ +
+Callout +
+

Optional parameters and return values

+
+

The optional parameter return_num changes the data type +that is returned by the function ski.measure.label. The +number of labels is only returned if return_num is +True. Otherwise, the function only returns the labeled image. +This means that we have to pay attention when assigning the return value +to a variable. If we omit the optional parameter return_num +or pass return_num=False, we can call the function as

+
+

PYTHON +

+
labeled_image = ski.measure.label(binary_mask)
+
+

If we pass return_num=True, the function returns a tuple +and we can assign it as

+
+

PYTHON +

+
labeled_image, count = ski.measure.label(binary_mask, return_num=True)
+
+

If we used the same assignment as in the first case, the variable +labeled_image would become a tuple, in which +labeled_image[0] is the image and +labeled_image[1] is the number of labels. This could cause +confusion if we assume that labeled_image only contains the +image and pass it to other functions. If you get an +AttributeError: 'tuple' object has no attribute 'shape' or +similar, check if you have assigned the return values consistently with +the optional parameters.

+
+
+
+

We can call the above function connected_components and +display the labeled image like so:

+
+

PYTHON +

+
labeled_image, count = connected_components(filename="data/shapes-01.jpg", sigma=2.0, t=0.9, connectivity=2)
+
+fig, ax = plt.subplots()
+ax.imshow(labeled_image)
+ax.set_axis_off();
+
+
+
+ +
+
+

If you are using an older version of Matplotlib you might get a +warning +UserWarning: Low image data range; displaying image with stretched contrast. +or just see a visually empty image.

+

What went wrong? When you hover over the image, the pixel values are +shown as numbers in the lower corner of the viewer. You can see that +some pixels have values different from 0, so they are not +actually all the same value. Let’s find out more by examining +labeled_image. Properties that might be interesting in this +context are dtype, the minimum and maximum value. We can +print them with the following lines:

+
+

PYTHON +

+
print("dtype:", labeled_image.dtype)
+print("min:", np.min(labeled_image))
+print("max:", np.max(labeled_image))
+
+

Examining the output can give us a clue why the image appears +empty.

+
+

OUTPUT +

+
dtype: int32
+min: 0
+max: 11
+
+

The dtype of labeled_image is +int32. This means that values in this image range from +-2 ** 31 to 2 ** 31 - 1. Those are really big +numbers. From this available space we only use the range from +0 to 11. When showing this image in the +viewer, it may squeeze the complete range into 256 gray values. +Therefore, the range of our numbers does not produce any visible +variation. One way to rectify this is to explicitly specify the data +range we want the colormap to cover:

+
+

PYTHON +

+
fig, ax = plt.subplots()
+ax.imshow(labeled_image, vmin=np.min(labeled_image), vmax=np.max(labeled_image))
+
+

Note this is the default behaviour for newer versions of +matplotlib.pyplot.imshow. Alternatively we could convert +the image to RGB and then display it.

+
+
+
+
+
+
+ +
+Callout +
+

Suppressing outputs in Jupyter Notebooks

+
+

We just used ax.set_axis_off(); to hide the axis from +the image for a visually cleaner figure. The semicolon is added to +supress the output(s) of the statement, in this case +the axis limits. This is specific to Jupyter Notebooks.

+
+
+
+

We can use the function ski.color.label2rgb() to convert +the 32-bit grayscale labeled image to standard RGB colour (recall that +we already used the ski.color.rgb2gray() function to +convert to grayscale). With ski.color.label2rgb(), all +objects are coloured according to a list of colours that can be +customised. We can use the following commands to convert and show the +image:

+
+

PYTHON +

+
# convert the label image to color image
+colored_label_image = ski.color.label2rgb(labeled_image, bg_label=0)
+
+fig, ax = plt.subplots()
+ax.imshow(colored_label_image)
+ax.set_axis_off();
+
+
Labeled objects
+
+ +
+Challenge +
+

How many objects are in that image (15 +min)

+
+

Now, it is your turn to practice. Using the function +connected_components, find two ways of printing out the +number of objects found in the image.

+

What number of objects would you expect to get?

+

How does changing the sigma and threshold +values influence the result?

+
+
+
+
+
+ +
+
+

As you might have guessed, the return value count +already contains the number of objects found in the image. So it can +simply be printed with

+
+

PYTHON +

+
print("Found", count, "objects in the image.")
+
+

But there is also a way to obtain the number of found objects from +the labeled image itself. Recall that all pixels that belong to a single +object are assigned the same integer value. The connected component +algorithm produces consecutive numbers. The background gets the value +0, the first object gets the value 1, the +second object the value 2, and so on. This means that by +finding the object with the maximum value, we also know how many objects +there are in the image. We can thus use the np.max function +from NumPy to find the maximum value that equals the number of found +objects:

+
+

PYTHON +

+
num_objects = np.max(labeled_image)
+print("Found", num_objects, "objects in the image.")
+
+

Invoking the function with sigma=2.0, and +threshold=0.9, both methods will print

+
+

OUTPUT +

+
Found 11 objects in the image.
+
+

Lowering the threshold will result in fewer objects. The higher the +threshold is set, the more objects are found. More and more background +noise gets picked up as objects. Larger sigmas produce binary masks with +less noise and hence a smaller number of objects. Setting sigma too high +bears the danger of merging objects.

+
+
+
+
+

You might wonder why the connected component analysis with +sigma=2.0, and threshold=0.9 finds 11 objects, +whereas we would expect only 7 objects. Where are the four additional +objects? With a bit of detective work, we can spot some small objects in +the image, for example, near the left border.

+
shapes-01.jpg mask detail

For us it is clear that these small spots are artifacts and not +objects we are interested in. But how can we tell the computer? One way +to calibrate the algorithm is to adjust the parameters for blurring +(sigma) and thresholding (t), but you may have +noticed during the above exercise that it is quite hard to find a +combination that produces the right output number. In some cases, +background noise gets picked up as an object. And with other parameters, +some of the foreground objects get broken up or disappear completely. +Therefore, we need other criteria to describe desired properties of the +objects that are found.

+

Morphometrics - Describe object features with numbers

+

Morphometrics is concerned with the quantitative analysis of objects +and considers properties such as size and shape. For the example of the +images with the shapes, our intuition tells us that the objects should +be of a certain size or area. So we could use a minimum area as a +criterion for when an object should be detected. To apply such a +criterion, we need a way to calculate the area of objects found by +connected components. Recall how we determined the root mass in the Thresholding episode by +counting the pixels in the binary mask. But here we want to calculate +the area of several objects in the labeled image. The scikit-image +library provides the function ski.measure.regionprops to +measure the properties of labeled regions. It returns a list of +RegionProperties that describe each connected region in the +images. The properties can be accessed using the attributes of the +RegionProperties data type. Here we will use the properties +"area" and "label". You can explore the +scikit-image documentation to learn about other properties +available.

+

We can get a list of areas of the labeled objects as follows:

+
+

PYTHON +

+
# compute object features and extract object areas
+object_features = ski.measure.regionprops(labeled_image)
+object_areas = [objf["area"] for objf in object_features]
+object_areas
+
+

This will produce the output

+
+

OUTPUT +

+
[318542, 1, 523204, 496613, 517331, 143, 256215, 1, 68, 338784, 265755]
+
+
+
+ +
+Challenge +
+

Plot a histogram of the object area +distribution (10 min)

+
+

Similar to how we determined a “good” threshold in the Thresholding episode, it is +often helpful to inspect the histogram of an object property. For +example, we want to look at the distribution of the object areas.

+
  1. Create and examine a histogram of the object areas +obtained with ski.measure.regionprops.
  2. +
  3. What does the histogram tell you about the objects?
  4. +
+
+
+
+
+ +
+
+

The histogram can be plotted with

+
+

PYTHON +

+
fig, ax = plt.subplots()
+ax.hist(object_areas)
+ax.set_xlabel("Area (pixels)")
+ax.set_ylabel("Number of objects");
+
+
Histogram of object areas

The histogram shows the number of objects (vertical axis) whose area +is within a certain range (horizontal axis). The height of the bars in +the histogram indicates the prevalence of objects with a certain area. +The whole histogram tells us about the distribution of object sizes in +the image. It is often possible to identify gaps between groups of bars +(or peaks if we draw the histogram as a continuous curve) that tell us +about certain groups in the image.

+

In this example, we can see that there are four small objects that +contain less than 50000 pixels. Then there is a group of four (1+1+2) +objects in the range between 200000 and 400000, and three objects with a +size around 500000. For our object count, we might want to disregard the +small objects as artifacts, i.e, we want to ignore the leftmost bar of +the histogram. We could use a threshold of 50000 as the minimum area to +count. In fact, the object_areas list already tells us that +there are fewer than 200 pixels in these objects. Therefore, it is +reasonable to require a minimum area of at least 200 pixels for a +detected object. In practice, finding the “right” threshold can be +tricky and usually involves an educated guess based on domain +knowledge.

+
+
+
+
+
+
+ +
+Challenge +
+

Filter objects by area (10 min)

+
+

Now we would like to use a minimum area criterion to obtain a more +accurate count of the objects in the image.

+
  1. Find a way to calculate the number of objects by only counting +objects above a certain area.
  2. +
+
+
+
+
+ +
+
+

One way to count only objects above a certain area is to first create +a list of those objects, and then take the length of that list as the +object count. This can be done as follows:

+
+

PYTHON +

+
min_area = 200
+large_objects = []
+for objf in object_features:
+    if objf["area"] > min_area:
+        large_objects.append(objf["label"])
+print("Found", len(large_objects), "objects!")
+
+

Another option is to use NumPy arrays to create the list of large +objects. We first create an array object_areas containing +the object areas, and an array object_labels containing the +object labels. The labels of the objects are also returned by +ski.measure.regionprops. We have already seen that we can +create boolean arrays using comparison operators. Here we can use +object_areas > min_area to produce an array that has the +same dimension as object_labels. It can then be used to +select the labels of objects whose area is greater than +min_area by indexing:

+
+

PYTHON +

+
object_areas = np.array([objf["area"] for objf in object_features])
+object_labels = np.array([objf["label"] for objf in object_features])
+large_objects = object_labels[object_areas > min_area]
+print("Found", len(large_objects), "objects!")
+
+

The advantage of using NumPy arrays is that for loops +and if statements in Python can be slow, and in practice +the first approach may not be feasible if the image contains a large +number of objects. In that case, NumPy array functions turn out to be +very useful because they are much faster.

+

In this example, we can also use the np.count_nonzero +function that we have seen earlier together with the > +operator to count the objects whose area is above +min_area.

+
+

PYTHON +

+
n = np.count_nonzero(object_areas > min_area)
+print("Found", n, "objects!")
+
+

For all three alternatives, the output is the same and gives the +expected count of 7 objects.

+
+
+
+
+
+
+ +
+Callout +
+

Using functions from NumPy and other Python +packages

+
+

Functions from Python packages such as NumPy are often more efficient +and require less code to write. It is a good idea to browse the +reference pages of numpy and skimage to look +for an availabe function that can solve a given task.

+
+
+
+
+
+ +
+Challenge +
+

Remove small objects (20 min)

+
+

We might also want to exclude (mask) the small objects when plotting +the labeled image.

+
  1. Enhance the connected_components function such that it +automatically removes objects that are below a certain area that is +passed to the function as an optional parameter.
  2. +
+
+
+
+
+ +
+
+

To remove the small objects from the labeled image, we change the +value of all pixels that belong to the small objects to the background +label 0. One way to do this is to loop over all objects and set the +pixels that match the label of the object to 0.

+
+

PYTHON +

+
for object_id, objf in enumerate(object_features, start=1):
+    if objf["area"] < min_area:
+        labeled_image[labeled_image == objf["label"]] = 0
+
+

Here NumPy functions can also be used to eliminate for +loops and if statements. Like above, we can create an array +of the small object labels with the comparison +object_areas < min_area. We can use another NumPy +function, np.isin, to set the pixels of all small objects +to 0. np.isin takes two arrays and returns a boolean array +with values True if the entry of the first array is found +in the second array, and False otherwise. This array can +then be used to index the labeled_image and set the entries +that belong to small objects to 0.

+
+

PYTHON +

+
object_areas = np.array([objf["area"] for objf in object_features])
+object_labels = np.array([objf["label"] for objf in object_features])
+small_objects = object_labels[object_areas < min_area]
+labeled_image[np.isin(labeled_image, small_objects)] = 0
+
+

An even more elegant way to remove small objects from the image is to +leverage the ski.morphology module. It provides a function +ski.morphology.remove_small_objects that does exactly what +we are looking for. It can be applied to a binary image and returns a +mask in which all objects smaller than min_area are +excluded, i.e, their pixel values are set to False. We can +then apply ski.measure.label to the masked image:

+
+

PYTHON +

+
object_mask = ski.morphology.remove_small_objects(binary_mask, min_size=min_area)
+labeled_image, n = ski.measure.label(object_mask,
+                                         connectivity=connectivity, return_num=True)
+
+

Using the scikit-image features, we can implement the +enhanced_connected_component as follows:

+
+

PYTHON +

+
def enhanced_connected_components(filename, sigma=1.0, t=0.5, connectivity=2, min_area=0):
+    image = iio.imread(filename)
+    gray_image = ski.color.rgb2gray(image)
+    blurred_image = ski.filters.gaussian(gray_image, sigma=sigma)
+    binary_mask = blurred_image < t
+    object_mask = ski.morphology.remove_small_objects(binary_mask, min_size=min_area)
+    labeled_image, count = ski.measure.label(object_mask,
+                                                 connectivity=connectivity, return_num=True)
+    return labeled_image, count
+
+

We can now call the function with a chosen min_area and +display the resulting labeled image:

+
+

PYTHON +

+
labeled_image, count = enhanced_connected_components(filename="data/shapes-01.jpg", sigma=2.0, t=0.9,
+                                                     connectivity=2, min_area=min_area)
+colored_label_image = ski.color.label2rgb(labeled_image, bg_label=0)
+
+fig, ax = plt.subplots()
+ax.imshow(colored_label_image)
+ax.set_axis_off();
+
+print("Found", count, "objects in the image.")
+
+
Objects filtered by area
+

OUTPUT +

+
Found 7 objects in the image.
+
+

Note that the small objects are “gone” and we obtain the correct +number of 7 objects in the image.

+
+
+
+
+
+
+ +
+Challenge +
+

Colour objects by area (optional, not included +in timing)

+
+

Finally, we would like to display the image with the objects coloured +according to the magnitude of their area. In practice, this can be used +with other properties to give visual cues of the object properties.

+
+
+
+
+
+ +
+
+

We already know how to get the areas of the objects from the +regionprops. We just need to insert a zero area value for +the background (to colour it like a zero size object). The background is +also labeled 0 in the labeled_image, so we +insert the zero area value in front of the first element of +object_areas with np.insert. Then we can +create a colored_area_image where we assign each pixel +value the area by indexing the object_areas with the label +values in labeled_image.

+
+

PYTHON +

+
object_areas = np.array([objf["area"] for objf in ski.measure.regionprops(labeled_image)])
+# prepend zero to object_areas array for background pixels
+object_areas = np.insert(0, obj=1, values=object_areas)
+# create image where the pixels in each object are equal to that object's area
+colored_area_image = object_areas[labeled_image]
+
+fig, ax = plt.subplots()
+im = ax.imshow(colored_area_image)
+cbar = fig.colorbar(im, ax=ax, shrink=0.85)
+cbar.ax.set_title("Area")
+ax.set_axis_off();
+
+
Objects colored by area
+
+ +
+Callout +
+
+

You may have noticed that in the solution, we have used the +labeled_image to index the array object_areas. +This is an example of advanced +indexing in NumPy The result is an array of the same shape as the +labeled_image whose pixel values are selected from +object_areas according to the object label. Hence the +objects will be colored by area when the result is displayed. Note that +advanced indexing with an integer array works slightly different than +the indexing with a Boolean array that we have used for masking. While +Boolean array indexing returns only the entries corresponding to the +True values of the index, integer array indexing returns an +array with the same shape as the index. You can read more about advanced +indexing in the NumPy +documentation.

+
+
+
+
+
+
+
+
+
+ +
+Key Points +
+
+
  • We can use ski.measure.label to find and label +connected objects in an image.
  • +
  • We can use ski.measure.regionprops to measure +properties of labeled objects.
  • +
  • We can use ski.morphology.remove_small_objects to mask +small objects and remove artifacts from an image.
  • +
  • We can display the labeled image to view the objects coloured by +label.
  • +
+
+
+
+
+ + +
+
+ + + diff --git a/09-challenges.html b/09-challenges.html new file mode 100644 index 000000000..36a7bb1aa --- /dev/null +++ b/09-challenges.html @@ -0,0 +1,699 @@ + +Image Processing with Python: Capstone Challenge +
+
+ + + + + +
+
+

Capstone Challenge

+

Last updated on 2026-03-23 | + + Edit this page

+ + + +
+ +
+ + + +
+

Overview

+
+
+
+
+

Questions

+
  • How can we automatically count bacterial colonies with image +analysis?
  • +
+
+
+
+
+
+

Objectives

+
  • Bring together everything you’ve learnt so far to count bacterial +colonies in 3 images.
  • +
+
+
+
+
+

In this episode, we will provide a final challenge for you to +attempt, based on all the skills you have acquired so far. This +challenge will be related to the shape of objects in images +(morphometrics).

+

Morphometrics: Bacteria Colony Counting

+

As mentioned in the workshop +introduction, your morphometric challenge is to determine how many +bacteria colonies are in each of these images:

+
Colony image 1
Colony image 2
Colony image 3

The image files can be found at data/colonies-01.tif, +data/colonies-02.tif, and +data/colonies-03.tif.

+
+
+ +
+Challenge +
+

Morphometrics for bacterial colonies

+
+

Write a Python program that uses scikit-image to count the number of +bacteria colonies in each image, and for each, produce a new image that +highlights the colonies. The image should look similar to this one:

+
Sample morphometric output

Additionally, print out the number of colonies for each image.

+

Use what you have learnt about histograms, thresholding and connected component analysis. +Try to put your code into a re-usable function, so that it can be +applied conveniently to any image file.

+
+
+
+
+
+ +
+
+

First, let’s work through the process for one image:

+
+

PYTHON +

+
import imageio.v3 as iio
+import ipympl
+import matplotlib.pyplot as plt
+import numpy as np
+import skimage as ski
+
+%matplotlib widget
+
+bacteria_image = iio.imread(uri="data/colonies-01.tif")
+
+# display the image
+fig, ax = plt.subplots()
+ax.imshow(bacteria_image)
+
+
Colony image 1

Next, we need to threshold the image to create a mask that covers +only the dark bacterial colonies. This is easier using a grayscale +image, so we convert it here:

+
+

PYTHON +

+
gray_bacteria = ski.color.rgb2gray(bacteria_image)
+
+# display the gray image
+fig, ax = plt.subplots()
+ax.imshow(gray_bacteria, cmap="gray")
+
+
Gray Colonies

Next, we blur the image and create a histogram:

+
+

PYTHON +

+
blurred_image = ski.filters.gaussian(gray_bacteria, sigma=1.0)
+histogram, bin_edges = np.histogram(blurred_image, bins=256, range=(0.0, 1.0))
+fig, ax = plt.subplots()
+ax.plot(bin_edges[0:-1], histogram)
+ax.set_title("Graylevel histogram")
+ax.set_xlabel("gray value")
+ax.set_ylabel("pixel count")
+ax.set_xlim(0, 1.0)
+
+
Histogram image

In this histogram, we see three peaks - the left one (i.e. the +darkest pixels) is our colonies, the central peak is the yellow/brown +culture medium in the dish, and the right one (i.e. the brightest +pixels) is the white image background. Therefore, we choose a threshold +that selects the small left peak:

+
+

PYTHON +

+
mask = blurred_image < 0.2
+fig, ax = plt.subplots()
+ax.imshow(mask, cmap="gray")
+
+
Colony mask image

This mask shows us where the colonies are in the image - but how can +we count how many there are? This requires connected component +analysis:

+
+

PYTHON +

+
labeled_image, count = ski.measure.label(mask, return_num=True)
+print(count)
+
+

Finally, we create the summary image of the coloured colonies on top +of the grayscale image:

+
+

PYTHON +

+
# color each of the colonies a different color
+colored_label_image = ski.color.label2rgb(labeled_image, bg_label=0)
+# give our grayscale image rgb channels, so we can add the colored colonies
+summary_image = ski.color.gray2rgb(gray_bacteria)
+summary_image[mask] = colored_label_image[mask]
+
+# plot overlay
+fig, ax = plt.subplots()
+ax.imshow(summary_image)
+
+
Sample morphometric output

Now that we’ve completed the task for one image, we need to repeat +this for the remaining two images. This is a good point to collect the +lines above into a re-usable function:

+
+

PYTHON +

+
def count_colonies(image_filename):
+    bacteria_image = iio.imread(image_filename)
+    gray_bacteria = ski.color.rgb2gray(bacteria_image)
+    blurred_image = ski.filters.gaussian(gray_bacteria, sigma=1.0)
+    mask = blurred_image < 0.2
+    labeled_image, count = ski.measure.label(mask, return_num=True)
+    print(f"There are {count} colonies in {image_filename}")
+
+    colored_label_image = ski.color.label2rgb(labeled_image, bg_label=0)
+    summary_image = ski.color.gray2rgb(gray_bacteria)
+    summary_image[mask] = colored_label_image[mask]
+    fig, ax = plt.subplots()
+    ax.imshow(summary_image)
+
+

Now we can do this analysis on all the images via a for loop:

+
+

PYTHON +

+
for image_filename in ["data/colonies-01.tif", "data/colonies-02.tif", "data/colonies-03.tif"]:
+    count_colonies(image_filename=image_filename)
+
+

Colony 1 outputColony 2 outputColony 3 output

+

You’ll notice that for the images with more colonies, the results +aren’t perfect. For example, some small colonies are missing, and there +are likely some small black spots being labelled incorrectly as +colonies. You could expand this solution to, for example, use an +automatically determined threshold for each image, which may fit each +better. Also, you could filter out colonies below a certain size (as we +did in the Connected +Component Analysis episode). You’ll also see that some touching +colonies are merged into one big colony. This could be fixed with more +complicated segmentation methods (outside of the scope of this lesson) +like watershed.

+
+
+
+
+
+
+ +
+Challenge +
+

Colony counting with minimum size and +automated threshold (optional, not included in timing)

+
+

Modify your function from the previous exercise for colony counting +to (i) exclude objects smaller than a specified size and (ii) use an +automated thresholding approach, e.g. Otsu, to mask the colonies.

+
+
+
+
+
+ +
+
+

Here is a modified function with the requested features. Note when +calculating the Otsu threshold we don’t include the very bright pixels +outside the dish.

+
+

PYTHON +

+
def count_colonies_enhanced(image_filename, sigma=1.0, min_colony_size=10, connectivity=2):
+    
+    bacteria_image = iio.imread(image_filename)
+    gray_bacteria = ski.color.rgb2gray(bacteria_image)
+    blurred_image = ski.filters.gaussian(gray_bacteria, sigma=sigma)
+    
+    # create mask excluding the very bright pixels outside the dish
+    # we dont want to include these when calculating the automated threshold
+    mask = blurred_image < 0.90
+    # calculate an automated threshold value within the dish using the Otsu method
+    t = ski.filters.threshold_otsu(blurred_image[mask])
+    # update mask to select pixels both within the dish and less than t
+    mask = np.logical_and(mask, blurred_image < t)
+    # remove objects smaller than specified area
+    mask = ski.morphology.remove_small_objects(mask, min_size=min_colony_size)
+    
+    labeled_image, count = ski.measure.label(mask, return_num=True)
+    print(f"There are {count} colonies in {image_filename}")
+    colored_label_image = ski.color.label2rgb(labeled_image, bg_label=0)
+    summary_image = ski.color.gray2rgb(gray_bacteria)
+    summary_image[mask] = colored_label_image[mask]
+    fig, ax = plt.subplots()
+    ax.imshow(summary_image)
+
+
+
+
+
+
+
+ +
+Key Points +
+
+
  • Using thresholding, connected component analysis and other tools we +can automatically segment images of bacterial colonies.
  • +
  • These methods are useful for many scientific problems, especially +those involving morphometrics.
  • +
+
+
+
+
+ +
+Discussion +
+

Where to go from here?

+
+

Take a look at our curated list of +resources for further publicly available courses, resources and +scientific literature around image processing and more.

+
+
+
+
+
+ + +
+
+ + + diff --git a/404.html b/404.html new file mode 100644 index 000000000..e461e986f --- /dev/null +++ b/404.html @@ -0,0 +1,421 @@ + +Image Processing with Python: Page not found +
+
+ + + + + +
+
+

Page not found

+ +

Our apologies!

+

We cannot seem to find the page you are looking for. Here are some +tips that may help:

+
  1. try going back to the previous +page or
  2. +
  3. navigate to any other page using the navigation bar on the +left.
  4. +
  5. if the URL ends with /index.html, try removing +that.
  6. +
  7. head over to the home page of this +lesson +
  8. +

If you came here from a link in this lesson, please contact the +lesson maintainers using the links at the foot of this page.

+
+
+ + +
+
+ + + diff --git a/AUTHORS b/AUTHORS deleted file mode 100644 index d59d5b86c..000000000 --- a/AUTHORS +++ /dev/null @@ -1 +0,0 @@ -Mark Meysenburg, mark.meysenburg@donae.edu diff --git a/CITATION b/CITATION deleted file mode 100644 index b638e5db3..000000000 --- a/CITATION +++ /dev/null @@ -1 +0,0 @@ -Image Processing workshop citation diff --git a/CODE_OF_CONDUCT.html b/CODE_OF_CONDUCT.html new file mode 100644 index 000000000..78967561d --- /dev/null +++ b/CODE_OF_CONDUCT.html @@ -0,0 +1,419 @@ + +Image Processing with Python: Contributor Code of Conduct +
+
+ + + + + +
+
+

Contributor Code of Conduct

+

Last updated on 2023-04-25 | + + Edit this page

+ + + +
+ +
+ + + +

As contributors and maintainers of this project, we pledge to follow +the The +Carpentries Code of Conduct.

+

Instances of abusive, harassing, or otherwise unacceptable behavior +may be reported by following our reporting +guidelines.

+ + + +
+
+ + +
+
+ + + diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md deleted file mode 100644 index f19b80495..000000000 --- a/CODE_OF_CONDUCT.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -title: "Contributor Code of Conduct" ---- - -As contributors and maintainers of this project, -we pledge to follow the [The Carpentries Code of Conduct][coc]. - -Instances of abusive, harassing, or otherwise unacceptable behavior -may be reported by following our [reporting guidelines][coc-reporting]. - - -[coc-reporting]: https://docs.carpentries.org/topic_folders/policies/incident-reporting.html -[coc]: https://docs.carpentries.org/topic_folders/policies/code-of-conduct.html diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 0a0a4f542..000000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,210 +0,0 @@ -## Contributing - -[The Carpentries][cp-site] ([Software Carpentry][swc-site], [Data -Carpentry][dc-site], and [Library Carpentry][lc-site]) are open source -projects, and we welcome contributions of all kinds: new lessons, fixes to -existing material, bug reports, and reviews of proposed changes are all -welcome. - -### Contributor Agreement - -By contributing, you agree that we may redistribute your work under [our -license](LICENSE.md). In exchange, we will address your issues and/or assess -your change proposal as promptly as we can, and help you become a member of our -community. Everyone involved in [The Carpentries][cp-site] agrees to abide by -our [code of conduct](CODE_OF_CONDUCT.md). - -### Who Should Contribute? - -Contributions to this lesson are welcome from anyone with an interest in the project. - -### How to Contribute - -The easiest way to get started is to file an issue to tell us about a spelling -mistake, some awkward wording, or a factual error. This is a good way to -introduce yourself and to meet some of our community members. - -1. If you do not have a [GitHub][github] account, you can [send us comments by - email][contact]. However, we will be able to respond more quickly if you use - one of the other methods described below. - -2. If you have a [GitHub][github] account, or are willing to [create - one][github-join], but do not know how to use Git, you can report problems - or suggest improvements by [creating an issue][issues]. This allows us to - assign the item to someone and to respond to it in a threaded discussion. - -3. If you are comfortable with Git, and would like to add or change material, - you can submit a pull request (PR). Instructions for doing this are - [included below](#using-github). - -Note: if you want to build the website locally, please refer to [The Workbench -documentation][template-doc]. - -### Where to Contribute - -1. If you wish to change this lesson, add issues and pull requests here. -2. If you wish to change the template used for workshop websites, please refer - to [The Workbench documentation][template-doc]. - - -### What to Contribute (General) - -There are many ways to contribute, from writing new exercises and improving -existing ones to updating or filling in the documentation and submitting [bug -reports][issues] about things that do not work, are not clear, or are missing. -If you are looking for ideas, please see [the list of issues for this -repository][repo], or the issues for [Data Carpentry][dc-issues], [Library -Carpentry][lc-issues], and [Software Carpentry][swc-issues] projects. - -Comments on issues and reviews of pull requests are just as welcome: we are -smarter together than we are on our own. **Reviews from novices and newcomers -are particularly valuable**: it's easy for people who have been using these -lessons for a while to forget how impenetrable some of this material can be, so -fresh eyes are always welcome. - -### What to Contribute (This Lesson) - -Any contributions are welcome, particularly ideas for how the existing content could be -improved or updated, and/or errors that need to be corrected. Comments on existing issues -and reviews of pull requests are similarly welcome. - -If you plan to submit a pull request, please open an issue -(or comment on an existing thread) first to ensure that effort is not duplicated -or spent making a change that will not be accepted by the Maintainers. - -#### Content / style guidelines - -- If you add an image / figure that was generated from Python code, please include this - code in your PR under `episodes/fig/source`. - -- If you add a new image or figure, verify that it displays correctly in the lesson’s dark mode. A color-inversion filter is applied [by Varnish to all images by default in dark mode](https://github.com/carpentries/varnish/issues/181), which may cause some images to appear incorrectly or become unreadable. If your image is affected, include an additional version of the same image with a `-dark` suffix in its filename. - -- Use the terms in the table below, when referring to Python libraries within the lesson. - The table gives two terms for each library: `Term for descriptive text` which should be - used when discussing the library in plain English / full sentences and `Term for code` - which should be used when referring to code (and within code). - - | Python library | Term for descriptive text | Term for code | - | :------------- | :------------- | :------------- | - | [scikit-image](https://scikit-image.org/) | scikit-image | `skimage` | - | [NumPy](https://numpy.org/) | NumPy | `numpy` | - | [Matplotlib](https://matplotlib.org/) | Matplotlib | `matplotlib` | - | [imageio](https://imageio.readthedocs.io/en/stable/index.html) | imageio | `imageio` | - - -- When importing scikit-image use: - ```python - import skimage as ski - ``` - Therefore, to access specific functions, you need to use their submodule name. For example: - - ```python - import skimage as ski - - rr, cc = ski.draw.rectangle(start=(357, 44), end=(740, 720)) - ``` - -- For reading and writing images, use the [imageio](https://imageio.readthedocs.io/en/stable/index.html) - library and avoid use of `skimage.io`. For example: - ```python - import imageio.v3 as iio - - chair = iio.imread(uri="data/chair.jpg") # read an image - iio.imwrite(uri="data/chair.tif", image=chair) # write an image - ``` - -- Comments providing an overall description of a code snippet should use triple quotes `"""`, e.g., - ```python - """Python script to load a colour image in grayscale""" - - chair = iio.imread(uri="data/chair.jpg") - gray_chair = ski.color.rgb2gray(chair) - ``` - -### What *Not* to Contribute (General) - -Our lessons already contain more material than we can cover in a typical -workshop, so we are usually *not* looking for more concepts or tools to add to -them. As a rule, if you want to introduce a new idea, you must (a) estimate how -long it will take to teach and (b) explain what you would take out to make room -for it. The first encourages contributors to be honest about requirements; the -second, to think hard about priorities. - -We are also not looking for exercises or other material that only run on one -platform. Our workshops typically contain a mixture of Windows, macOS, and -Linux users; in order to be usable, our lessons must run equally well on all -three. - -### What *Not* to Contribute (This Lesson) - -Although most contributions will be welcome at this stage of the curriculum's development, -the time available to deliver the content in a training event is strictly limited -and needs to be accounted for when considering the addition of any new content. -If you want to suggest the addition of new content, especially whole new sections or episodes, -please open an issue to discuss this with the Maintainers first and provide the following -information alongside a summary of the content to be added: - -1. A suggested location for the new content. -2. An estimate of how much time you estimate the new content would require in training - (teaching + exercises). -3. The [learning objective(s)][cldt-lo] of this new content. -4. (optional, but strongly preferred) - A suggestion of which of the currently-used learning objectives could be - removed from the curriculum to make space for the new content. - -### Using GitHub - -If you choose to contribute via GitHub, you may want to look at [How to -Contribute to an Open Source Project on GitHub][how-contribute]. In brief, we -use [GitHub flow][github-flow] to manage changes: - -1. Create a new branch in your desktop copy of this repository for each - significant change. -2. Commit the change in that branch. -3. Push that branch to your fork of this repository on GitHub. -4. Submit a pull request from that branch to the [upstream repository][repo]. -5. If you receive feedback, make changes on your desktop and push to your - branch on GitHub: the pull request will update automatically. - -NB: The published copy of the lesson is usually in the `main` branch. - -Each lesson has a team of maintainers who review issues and pull requests or -encourage others to do so. The maintainers are community volunteers, and have -final say over what gets merged into the lesson. - -#### Merging Policy - -Pull requests made to the default branch of this repository -(from which the lesson site is built) -can only be merged after at least one approving review from a Maintainer. -Any Maintainer can merge a pull request that has received at least one approval, -but they may prefer to wait for further input from others before merging. - -### Other Resources - -The Carpentries is a global organisation with volunteers and learners all over -the world. We share values of inclusivity and a passion for sharing knowledge, -teaching and learning. There are several ways to connect with The Carpentries -community listed at including via social -media, slack, newsletters, and email lists. You can also [reach us by -email][contact]. - -[repo]: https://github.com/datacarpentry/image-processing -[cldt-lo]: https://carpentries.github.io/lesson-development-training/05-objectives.html#learning-objectives -[contact]: mailto:team@carpentries.org -[cp-site]: https://carpentries.org/ -[dc-issues]: https://github.com/issues?q=user%3Adatacarpentry -[dc-lessons]: https://datacarpentry.org/lessons/ -[dc-site]: https://datacarpentry.org/ -[discuss-list]: https://lists.software-carpentry.org/listinfo/discuss -[github]: https://github.com -[github-flow]: https://guides.github.com/introduction/flow/ -[github-join]: https://github.com/join -[how-contribute]: https://egghead.io/courses/how-to-contribute-to-an-open-source-project-on-github -[issues]: https://carpentries.org/help-wanted-issues/ -[lc-issues]: https://github.com/issues?q=user%3ALibraryCarpentry -[swc-issues]: https://github.com/issues?q=user%3Aswcarpentry -[swc-lessons]: https://software-carpentry.org/lessons/ -[swc-site]: https://software-carpentry.org/ -[lc-site]: https://librarycarpentry.org/ -[template-doc]: https://carpentries.github.io/workbench/ diff --git a/LICENSE.html b/LICENSE.html new file mode 100644 index 000000000..dbaf810b2 --- /dev/null +++ b/LICENSE.html @@ -0,0 +1,467 @@ + +Image Processing with Python: Licenses +
+
+ + + + + +
+
+

Licenses

+

Last updated on 2025-02-04 | + + Edit this page

+ + + +
+ +
+ + + +

Instructional Material

+

All Carpentries (Software Carpentry, Data Carpentry, and Library +Carpentry) instructional material is made available under the Creative Commons +Attribution license. The following is a human-readable summary of +(and not a substitute for) the full legal +text of the CC BY 4.0 license.

+

You are free:

+
  • to Share—copy and redistribute the material in any +medium or format
  • +
  • to Adapt—remix, transform, and build upon the +material
  • +

for any purpose, even commercially.

+

The licensor cannot revoke these freedoms as long as you follow the +license terms.

+

Under the following terms:

+
  • Attribution—You must give appropriate credit +(mentioning that your work is derived from work that is Copyright (c) +The Carpentries and, where practical, linking to https://carpentries.org/), provide a link to the +license, and indicate if changes were made. You may do so in any +reasonable manner, but not in any way that suggests the licensor +endorses you or your use.

  • +
  • No additional restrictions—You may not apply +legal terms or technological measures that legally restrict others from +doing anything the license permits. With the understanding +that:

  • +

Notices:

+
  • You do not have to comply with the license for elements of the +material in the public domain or where your use is permitted by an +applicable exception or limitation.
  • +
  • No warranties are given. The license may not give you all of the +permissions necessary for your intended use. For example, other rights +such as publicity, privacy, or moral rights may limit how you use the +material.
  • +

Software

+

Except where otherwise noted, the example programs and other software +provided by The Carpentries are made available under the OSI-approved MIT +license.

+

Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +“Software”), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions:

+

The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software.

+

THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

+

Trademark

+

“The Carpentries”, “Software Carpentry”, “Data Carpentry”, and +“Library Carpentry” and their respective logos are registered trademarks +of The Carpentries, Inc..

+
+
+ + +
+
+ + + diff --git a/LICENSE.md b/LICENSE.md deleted file mode 100644 index fd13a1b63..000000000 --- a/LICENSE.md +++ /dev/null @@ -1,79 +0,0 @@ ---- -title: "Licenses" ---- - -## Instructional Material - -All Carpentries (Software Carpentry, Data Carpentry, and Library Carpentry) -instructional material is made available under the [Creative Commons -Attribution license][cc-by-human]. The following is a human-readable summary of -(and not a substitute for) the [full legal text of the CC BY 4.0 -license][cc-by-legal]. - -You are free: - -- to **Share**---copy and redistribute the material in any medium or format -- to **Adapt**---remix, transform, and build upon the material - -for any purpose, even commercially. - -The licensor cannot revoke these freedoms as long as you follow the license -terms. - -Under the following terms: - -- **Attribution**---You must give appropriate credit (mentioning that your work - is derived from work that is Copyright (c) The Carpentries and, where - practical, linking to ), provide a [link to the - license][cc-by-human], and indicate if changes were made. You may do so in - any reasonable manner, but not in any way that suggests the licensor endorses - you or your use. - -- **No additional restrictions**---You may not apply legal terms or - technological measures that legally restrict others from doing anything the - license permits. With the understanding that: - -Notices: - -* You do not have to comply with the license for elements of the material in - the public domain or where your use is permitted by an applicable exception - or limitation. -* No warranties are given. The license may not give you all of the permissions - necessary for your intended use. For example, other rights such as publicity, - privacy, or moral rights may limit how you use the material. - -## Software - -Except where otherwise noted, the example programs and other software provided -by The Carpentries are made available under the [OSI][osi]-approved [MIT -license][mit-license]. - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies -of the Software, and to permit persons to whom the Software is furnished to do -so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - -## Trademark - -"The Carpentries", "Software Carpentry", "Data Carpentry", and "Library -Carpentry" and their respective logos are registered trademarks of -[The Carpentries, Inc.][carpentries]. - -[cc-by-human]: https://creativecommons.org/licenses/by/4.0/ -[cc-by-legal]: https://creativecommons.org/licenses/by/4.0/legalcode -[mit-license]: https://opensource.org/licenses/mit-license.html -[carpentries]: https://carpentries.org -[osi]: https://opensource.org diff --git a/README.md b/README.md deleted file mode 100644 index 02f847b55..000000000 --- a/README.md +++ /dev/null @@ -1,35 +0,0 @@ -[![Create a Slack Account with us](https://img.shields.io/badge/Create_Slack_Account-The_Carpentries-071159.svg)](https://slack-invite.carpentries.org/) -[![Slack Status](https://img.shields.io/badge/Slack_Channel-dc--image--processing-E01563.svg)](https://carpentries.slack.com/archives/C027H977ZGU) - -# Image Processing with Python - -A lesson teaching foundational image processing skills with Python and [scikit-image](https://scikit-image.org/). - -## Lesson Content - -This lesson introduces fundamental concepts in image handling and processing. Learners will gain the skills needed to load images into Python, to select, summarise, and modify specific regions in these images, and to identify and extract objects within an image for further analysis. - -The lesson assumes a working knowledge of Python and some previous exposure to the Bash shell. -A detailed list of prerequisites can be found in [`learners/prereqs.md`](learners/prereqs.md). - -## Contribution - -- Make a suggestion or correct an error by [raising an Issue](https://github.com/datacarpentry/image-processing/issues). - -Please see the [CONTRIBUTING.md file](CONTRIBUTING.md) for contributing guidelines and details on how to get involved with -this project. Some specific guidelines for content / style are provided in the -['What to Contribute (This Lesson)' section](CONTRIBUTING.md#what-to-contribute-this-lesson). - -## Code of Conduct - -All participants should agree to abide by the [The Carpentries Code of Conduct](https://docs.carpentries.org/topic_folders/policies/code-of-conduct.html). - -## Lesson Maintainers - -The Image Processing with Python lesson is currently being maintained by: - -- [Kimberly Meechan](https://github.com/K-Meech) -- [Ulf Schiller](https://github.com/uschille) -- [Marco Dalla Vecchia](https://github.com/marcodallavecchia) - -The lesson is built on content originally developed by [Mark Meysenburg](https://github.com/mmeysenburg), [Tessa Durham Brooks](https://github.com/tessalea), [Dominik Kutra](https://github.com/k-dominik), [Constantin Pape](https://github.com/constantinpape), and [Erin Becker](https://github.com/ebecker). diff --git a/aio.html b/aio.html new file mode 100644 index 000000000..944560ca0 --- /dev/null +++ b/aio.html @@ -0,0 +1,6258 @@ + + + + + +Image Processing with Python: All in One View + + + + + + + + + + + + +
+
+ + + + + + +
+
+

All in One View

+ +

Content from Introduction

+
+

Last updated on 2024-11-28 | + + Edit this page

+
+ +
+
+

Overview

+
+
+
+
+

Questions

+
    +
  • What sort of scientific questions can we answer with image +processing / computer vision?
  • +
  • What are morphometric problems?
  • +
+
+
+
+
+
+
+

Objectives

+
    +
  • Recognise scientific questions that could be solved with image +processing / computer vision.
  • +
  • Recognise morphometric problems (those dealing with the number, +size, or shape of the objects in an image).
  • +
+
+
+
+
+
+

As computer systems have become faster and more powerful, and cameras +and other imaging systems have become commonplace in many other areas of +life, the need has grown for researchers to be able to process and +analyse image data. Considering the large volumes of data that can be +involved - high-resolution images that take up a lot of disk +space/virtual memory, and/or collections of many images that must be +processed together - and the time-consuming and error-prone nature of +manual processing, it can be advantageous or even necessary for this +processing and analysis to be automated as a computer program.

+

This lesson introduces an open source toolkit for processing image +data: the Python programming language and the scikit-image +(skimage) library. With careful experimental design, +Python code can be a powerful instrument in answering many different +kinds of questions.

+

Uses of Image Processing in Research +

+
+

Automated processing can be used to analyse many different properties +of an image, including the distribution and change in colours in the +image, the number, size, position, orientation, and shape of objects in +the image, and even - when combined with machine learning techniques for +object recognition - the type of objects in the image.

+

Some examples of image processing methods applied in research +include:

+ +

With this lesson, we aim to provide a thorough grounding in the +fundamental concepts and skills of working with image data in Python. +Most of the examples used in this lesson focus on one particular class +of image processing technique, morphometrics, but what you will +learn can be used to solve a much wider range of problems.

+

Morphometrics +

+
+

Morphometrics involves counting the number of objects in an image, +analyzing the size of the objects, or analyzing the shape of the +objects. For example, we might be interested in automatically counting +the number of bacterial colonies growing in a Petri dish, as shown in +this image:

+
Bacteria colony

We could use image processing to find the colonies, count them, and +then highlight their locations on the original image, resulting in an +image like this:

+
Colonies counted
+
+ +
+Callout +
+

Why write a program to do that?

+
+

Note that you can easily manually count the number of bacteria +colonies shown in the morphometric example above. Why should we learn +how to write a Python program to do a task we could easily perform with +our own eyes? There are at least two reasons to learn how to perform +tasks like these with Python and scikit-image:

+
    +
  1. What if there are many more bacteria colonies in the Petri dish? For +example, suppose the image looked like this:
  2. +
+
Bacteria colony

Manually counting the colonies in that image would present more of a +challenge. A Python program using scikit-image could count the number of +colonies more accurately, and much more quickly, than a human could.

+
    +
  1. What if you have hundreds, or thousands, of images to consider? +Imagine having to manually count colonies on several thousand images +like those above. A Python program using scikit-image could move through +all of the images in seconds; how long would a graduate student require +to do the task? Which process would be more accurate and +repeatable?
  2. +
+

As you can see, the simple image processing / computer vision +techniques you will learn during this workshop can be very valuable +tools for scientific research.

+
+
+
+

As we move through this workshop, we will learn image analysis +methods useful for many different scientific problems. These will be +linked together and applied to a real problem in the final +end-of-workshop capstone challenge.

+

Let’s get started, by learning some basics about how images are +represented and stored digitally.

+
+
+ +
+Key Points +
+
+
    +
  • Simple Python and scikit-image techniques can be used to solve +genuine image analysis problems.
  • +
  • Morphometric problems involve the number, shape, and / or size of +the objects in an image.
  • +
+
+
+
+

Content from Image Basics

+
+

Last updated on 2024-12-01 | + + Edit this page

+
+ +
+
+

Overview

+
+
+
+
+

Questions

+
    +
  • How are images represented in digital format?
  • +
+
+
+
+
+
+
+

Objectives

+
    +
  • Define the terms bit, byte, kilobyte, megabyte, etc.
  • +
  • Explain how a digital image is composed of pixels.
  • +
  • Recommend using imageio (resp. scikit-image) for I/O (resp. image +processing) tasks.
  • +
  • Explain how images are stored in NumPy arrays.
  • +
  • Explain the left-hand coordinate system used in digital images.
  • +
  • Explain the RGB additive colour model used in digital images.
  • +
  • Explain the order of the three colour values in scikit-image +images.
  • +
  • Explain the characteristics of the BMP, JPEG, and TIFF image +formats.
  • +
  • Explain the difference between lossy and lossless compression.
  • +
  • Explain the advantages and disadvantages of compressed image +formats.
  • +
  • Explain what information could be contained in image metadata.
  • +
+
+
+
+
+
+

The images we see on hard copy, view with our electronic devices, or +process with our programs are represented and stored in the computer as +numeric abstractions, approximations of what we see with our eyes in the +real world. Before we begin to learn how to process images with Python +programs, we need to spend some time understanding how these +abstractions work.

+
+
+ +
+Callout +
+
+

Feel free to make use of the available cheat-sheet as a guide for +the rest of the course material. View it online, share it, or print the +PDF!

+
+
+
+

Pixels +

+
+

It is important to realise that images are stored as rectangular +arrays of hundreds, thousands, or millions of discrete “picture +elements,” otherwise known as pixels. Each pixel can be thought +of as a single square point of coloured light.

+

For example, consider this image of a maize seedling, with a square +area designated by a red box:

+
Original size image

Now, if we zoomed in close enough to see the pixels in the red box, +we would see something like this:

+
Enlarged image area

Note that each square in the enlarged image area - each pixel - is +all one colour, but that each pixel can have a different colour from its +neighbors. Viewed from a distance, these pixels seem to blend together +to form the image we see.

+

Real-world images are typically made up of a vast number of pixels, +and each of these pixels is one of potentially millions of colours. +While we will deal with pictures of such complexity in this lesson, +let’s start our exploration with just 15 pixels in a 5 x 3 matrix with 2 +colours, and work our way up to that complexity.

+
+
+ +
+Callout +
+

Matrices, arrays, images and pixels

+
+

A matrix is a mathematical concept - numbers evenly +arranged in a rectangle. This can be a two-dimensional rectangle, like +the shape of the screen you’re looking at now. Or it could be a +three-dimensional equivalent, a cuboid, or have even more dimensions, +but always keeping the evenly spaced arrangement of numbers. In +computing, an array refers to a structure in the +computer’s memory where data is stored in evenly spaced +elements. This is strongly analogous to a matrix. A +NumPy array is a type of variable (a simpler example of +a type is an integer). For our purposes, the distinction between +matrices and arrays is not important, we don’t really care how the +computer arranges our data in its memory. The important thing is that +the computer stores values describing the pixels in images, as arrays. +And the terms matrix and array will be used interchangeably.

+
+
+
+

Loading images +

+
+

As noted, images we want to analyze (process) with Python are loaded +into arrays. There are multiple ways to load images. In this lesson, we +use imageio, a Python library for reading (loading) and writing (saving) +image data, and more specifically its version 3. But, really, we could +use any image loader which would return a NumPy array.

+
+

PYTHON +

+
"""Python library for reading and writing images."""
+
+import imageio.v3 as iio
+
+

The v3 module of imageio (imageio.v3) is +imported as iio (see note in the next section). Version 3 +of imageio has the benefit of supporting nD (multidimensional) image +data natively (think of volumes, movies).

+

Let us load our image data from disk using the imread +function from the imageio.v3 module.

+
+

PYTHON +

+
eight = iio.imread(uri="data/eight.tif")
+print(type(eight))
+
+
+

OUTPUT +

+
<class 'numpy.ndarray'>
+
+

Note that, using the same image loader or a different one, we could +also read in remotely hosted data.

+
+
+ +
+Callout +
+

Why not use +skimage.io.imread()?

+
+

The scikit-image library has its own function to read an image, so +you might be asking why we don’t use it here. Actually, +skimage.io.imread() uses iio.imread() +internally when loading an image into Python. It is certainly something +you may use as you see fit in your own code. In this lesson, we use the +imageio library to read or write images, while scikit-image is dedicated +to performing operations on the images. Using imageio gives us more +flexibility, especially when it comes to handling metadata.

+
+
+
+
+
+ +
+Callout +
+

Beyond NumPy arrays

+
+

Beyond NumPy arrays, there exist other types of variables which are +array-like. Notably, pandas.DataFrame +and xarray.DataArray +can hold labeled, tabular data. These are not natively supported in +scikit-image, the scientific toolkit we use in this lesson for +processing image data. However, data stored in these types can be +converted to numpy.ndarray with certain assumptions (see +pandas.DataFrame.to_numpy() and +xarray.DataArray.data). Particularly, these conversions +ignore the sampling coordinates (DataFrame.index, +DataFrame.columns, or DataArray.coords), which +may result in misrepresented data, for instance, when the original data +points are irregularly spaced.

+
+
+
+

Working with pixels +

+
+

First, let us add the necessary imports:

+
+

PYTHON +

+
"""Python libraries for learning and performing image processing."""
+
+import ipympl
+import matplotlib.pyplot as plt
+import numpy as np
+import skimage as ski
+
+
+
+ +
+Callout +
+

Import statements in Python

+
+

In Python, the import statement is used to load +additional functionality into a program. This is necessary when we want +our code to do something more specialised, which cannot easily be +achieved with the limited set of basic tools and data structures +available in the default Python environment.

+

Additional functionality can be loaded as a single function or +object, a module defining several of these, or a library containing many +modules. You will encounter several different forms of +import statement.

+
+

PYTHON +

+
import skimage                 # form 1, load whole skimage library
+import skimage.draw            # form 2, load skimage.draw module only
+from skimage.draw import disk  # form 3, load only the disk function
+import skimage as ski          # form 4, load all of skimage into an object called ski
+
+
+
+ +
+
+

In the example above, form 1 loads the entire scikit-image library +into the program as an object. Individual modules of the library are +then available within that object, e.g., to access the disk +function used in the drawing episode, you +would write skimage.draw.disk().

+

Form 2 loads only the draw module of +skimage into the program. The syntax needed to use the +module remains unchanged: to access the disk function, we +would use the same function call as given for form 1.

+

Form 3 can be used to import only a specific function/class from a +library/module. Unlike the other forms, when this approach is used, the +imported function or class can be called by its name only, without +prefixing it with the name of the library/module from which it was +loaded, i.e., disk() instead of +skimage.draw.disk() using the example above. One hazard of +this form is that importing like this will overwrite any object with the +same name that was defined/imported earlier in the program, i.e., the +example above would replace any existing object called disk +with the disk function from skimage.draw.

+

Finally, the as keyword can be used when importing, to +define a name to be used as shorthand for the library/module being +imported. This name is referred to as an alias. Typically, using an +alias (such as np for the NumPy library) saves us a little +typing. You may see as combined with any of the other first +three forms of import statements.

+

Which form is used often depends on the size and number of additional +tools being loaded into the program.

+
+
+
+
+
+
+
+

Now that we have our libraries loaded, we will run a Jupyter Magic +Command that will ensure our images display in our Jupyter document with +pixel information that will help us more efficiently run commands later +in the session.

+
+

PYTHON +

+
%matplotlib widget
+
+

With that taken care of, let us display the image we have loaded, +using the imshow function from the +matplotlib.pyplot module.

+
+

PYTHON +

+
fig, ax = plt.subplots()
+ax.imshow(eight)
+
+
Image of 8

You might be thinking, “That does look vaguely like an eight, and I +see two colours but how can that be only 15 pixels”. The display of the +eight you see does use a lot more screen pixels to display our eight so +large, but that does not mean there is information for all those screen +pixels in the file. All those extra pixels are a consequence of our +viewer creating additional pixels through interpolation. It could have +just displayed it as a tiny image using only 15 screen pixels if the +viewer was designed differently.

+

While many image file formats contain descriptive metadata that can +be essential, the bulk of a picture file is just arrays of numeric +information that, when interpreted according to a certain rule set, +become recognizable as an image to us. Our image of an eight is no +exception, and imageio.v3 stored that image data in an +array of arrays making a 5 x 3 matrix of 15 pixels. We can demonstrate +that by calling on the shape property of our image variable and see the +matrix by printing our image variable to the screen.

+
+

PYTHON +

+
print(eight.shape)
+print(eight)
+
+
+

OUTPUT +

+
(5, 3)
+[[0. 0. 0.]
+ [0. 1. 0.]
+ [0. 0. 0.]
+ [0. 1. 0.]
+ [0. 0. 0.]]
+
+

Thus if we have tools that will allow us to manipulate these arrays +of numbers, we can manipulate the image. The NumPy library can be +particularly useful here, so let’s try that out using NumPy array +slicing. Notice that the default behavior of the imshow +function appended row and column numbers that will be helpful to us as +we try to address individual or groups of pixels. First let’s load +another copy of our eight, and then make it look like a zero.

+

To make it look like a zero, we need to change the number underlying +the centremost pixel to be 1. With the help of those row and column +headers, at this small scale we can determine the centre pixel is in row +labeled 2 and column labeled 1. Using array slicing, we can then address +and assign a new value to that position.

+
+

PYTHON +

+
zero = iio.imread(uri="data/eight.tif")
+zero[2, 1]= 1.0
+
+# The following line of code creates a new figure for imshow to use in displaying our output.
+fig, ax = plt.subplots()
+ax.imshow(zero)
+print(zero)
+
+
+

OUTPUT +

+
[[0. 0. 0.]
+ [0. 1. 0.]
+ [0. 1. 0.]
+ [0. 1. 0.]
+ [0. 0. 0.]]
+
+
Image of 0
+
+ +
+Callout +
+

Coordinate system

+
+

When we process images, we can access, examine, and / or change the +colour of any pixel we wish. To do this, we need some convention on how +to access pixels individually; a way to give each one a name, or an +address of a sort.

+

The most common manner to do this, and the one we will use in our +programs, is to assign a modified Cartesian coordinate system to the +image. The coordinate system we usually see in mathematics has a +horizontal x-axis and a vertical y-axis, like this:

+
Cartesian coordinate system

The modified coordinate system used for our images will have only +positive coordinates, the origin will be in the upper left corner +instead of the centre, and y coordinate values will get larger as they +go down instead of up, like this:

+
Image coordinate system

This is called a left-hand coordinate system. If you hold +your left hand in front of your face and point your thumb at the floor, +your extended index finger will correspond to the x-axis while your +thumb represents the y-axis.

+
Left-hand coordinate system

Until you have worked with images for a while, the most common +mistake that you will make with coordinates is to forget that y +coordinates get larger as they go down instead of up as in a normal +Cartesian coordinate system. Consequently, it may be helpful to think in +terms of counting down rows (r) for the y-axis and across columns (c) +for the x-axis. This can be especially helpful in cases where you need +to transpose image viewer data provided in x,y format to +y,x format. Thus, we will use cx and ry where +appropriate to help bridge these two approaches.

+
+
+
+
+
+ +
+Challenge +
+

Changing Pixel Values (5 min)

+
+

Load another copy of eight named five, and then change the value of +pixels so you have what looks like a 5 instead of an 8. Display the +image and print out the matrix as well.

+
+
+
+
+
+ +
+
+

There are many possible solutions, but one method would be . . .

+
+

PYTHON +

+
five = iio.imread(uri="data/eight.tif")
+five[1, 2] = 1.0
+five[3, 0] = 1.0
+fig, ax = plt.subplots()
+ax.imshow(five)
+print(five)
+
+
+

OUTPUT +

+
[[0. 0. 0.]
+ [0. 1. 1.]
+ [0. 0. 0.]
+ [1. 1. 0.]
+ [0. 0. 0.]]
+
+
Image of 5
+
+
+
+
+

More colours +

+
+

Up to now, we only had a 2 colour matrix, but we can have more if we +use other numbers or fractions. One common way is to use the numbers +between 0 and 255 to allow for 256 different colours or 256 different +levels of grey. Let’s try that out.

+
+

PYTHON +

+
# make a copy of eight
+three_colours = iio.imread(uri="data/eight.tif")
+
+# multiply the whole matrix by 128
+three_colours = three_colours * 128
+
+# set the middle row (index 2) to the value of 255.,
+# so you end up with the values 0., 128., and 255.
+three_colours[2, :] = 255.
+fig, ax = plt.subplots()
+ax.imshow(three_colours)
+print(three_colours)
+
+
Image of three colours

We now have 3 colours, but are they the three colours you expected? +They all appear to be on a continuum of dark purple on the low end and +yellow on the high end. This is a consequence of the default colour map +(cmap) in this library. You can think of a colour map as an association +or mapping of numbers to a specific colour. However, the goal here is +not to have one number for every possible colour, but rather to have a +continuum of colours that demonstrate relative intensity. In our +specific case here for example, 255 or the highest intensity is mapped +to yellow, and 0 or the lowest intensity is mapped to a dark purple. The +best colour map for your data will vary and there are many options built +in, but this default selection was not arbitrary. A lot of science went +into making this the default due to its robustness when it comes to how +the human mind interprets relative colour values, grey-scale +printability, and colour-blind friendliness (You can read more about +this default colour map in a +Matplotlib tutorial and an explanatory article by the +authors). Thus it is a good place to start, and you should change it +only with purpose and forethought. For now, let’s see how you can do +that using an alternative map you have likely seen before where it will +be even easier to see it as a mapped continuum of intensities: +greyscale.

+
+

PYTHON +

+
fig, ax = plt.subplots()
+ax.imshow(three_colours, cmap="gray")
+
+
Image in greyscale

Above we have exactly the same underlying data matrix, but in +greyscale. Zero maps to black, 255 maps to white, and 128 maps to medium +grey. Here we only have a single channel in the data and utilize a +grayscale color map to represent the luminance, or intensity of the data +and correspondingly this channel is referred to as the luminance +channel.

+

Even more colours +

+
+

This is all well and good at this scale, but what happens when we +instead have a picture of a natural landscape that contains millions of +colours. Having a one to one mapping of number to colour like this would +be inefficient and make adjustments and building tools to do so very +difficult. Rather than larger numbers, the solution is to have more +numbers in more dimensions. Storing the numbers in a multi-dimensional +matrix where each colour or property like transparency is associated +with its own dimension allows for individual contributions to a pixel to +be adjusted independently. This ability to manipulate properties of +groups of pixels separately will be key to certain techniques explored +in later chapters of this lesson. To get started let’s see an example of +how different dimensions of information combine to produce a set of +pixels using a 4 x 4 matrix with 3 dimensions for the colours red, +green, and blue. Rather than loading it from a file, we will generate +this example using NumPy.

+
+

PYTHON +

+
# set the random seed so we all get the same matrix
+pseudorandomizer = np.random.RandomState(2021)
+# create a 4 × 4 checkerboard of random colours
+checkerboard = pseudorandomizer.randint(0, 255, size=(4, 4, 3))
+# restore the default map as you show the image
+fig, ax = plt.subplots()
+ax.imshow(checkerboard)
+# display the arrays
+print(checkerboard)
+
+
+

OUTPUT +

+
[[[116  85  57]
+  [128 109  94]
+  [214  44  62]
+  [219 157  21]]
+
+ [[ 93 152 140]
+  [246 198 102]
+  [ 70  33 101]
+  [  7   1 110]]
+
+ [[225 124 229]
+  [154 194 176]
+  [227  63  49]
+  [144 178  54]]
+
+ [[123 180  93]
+  [120   5  49]
+  [166 234 142]
+  [ 71  85  70]]]
+
+
Image of checkerboard

Previously we had one number being mapped to one colour or intensity. +Now we are combining the effect of 3 numbers to arrive at a single +colour value. Let’s see an example of that using the blue square at the +end of the second row, which has the index [1, 3].

+
+

PYTHON +

+
# extract all the colour information for the blue square
+upper_right_square = checkerboard[1, 3, :]
+upper_right_square
+
+

This outputs: array([ 7, 1, 110]) The integers in order represent +Red, Green, and Blue. Looking at the 3 values and knowing how they map, +can help us understand why it is blue. If we divide each value by 255, +which is the maximum, we can determine how much it is contributing +relative to its maximum potential. Effectively, the red is at 7/255 or +2.8 percent of its potential, the green is at 1/255 or 0.4 percent, and +blue is 110/255 or 43.1 percent of its potential. So when you mix those +three intensities of colour, blue is winning by a wide margin, but the +red and green still contribute to make it a slightly different shade of +blue than 0,0,110 would be on its own.

+

These colours mapped to dimensions of the matrix may be referred to +as channels. It may be helpful to display each of these channels +independently, to help us understand what is happening. We can do that +by multiplying our image array representation with a 1d matrix that has +a one for the channel we want to keep and zeros for the rest.

+
+

PYTHON +

+
red_channel = checkerboard * [1, 0, 0]
+fig, ax = plt.subplots()
+ax.imshow(red_channel)
+
+
Image of red channel
+

PYTHON +

+
green_channel = checkerboard * [0, 1, 0]
+fig, ax = plt.subplots()
+ax.imshow(green_channel)
+
+
Image of green channel
+

PYTHON +

+
blue_channel = checkerboard * [0, 0, 1]
+fig, ax = plt.subplots()
+ax.imshow(blue_channel)
+
+
Image of blue channel

If we look at the upper [1, 3] square in all three figures, we can +see each of those colour contributions in action. Notice that there are +several squares in the blue figure that look even more intensely blue +than square [1, 3]. When all three channels are combined though, the +blue light of those squares is being diluted by the relative strength of +red and green being mixed in with them.

+

24-bit RGB colour +

+
+

This last colour model we used, known as the RGB (Red, Green, +Blue) model, is the most common.

+

As we saw, the RGB model is an additive colour model, which +means that the primary colours are mixed together to form other colours. +Most frequently, the amount of the primary colour added is represented +as an integer in the closed range [0, 255] as seen in the example. +Therefore, there are 256 discrete amounts of each primary colour that +can be added to produce another colour. The number of discrete amounts +of each colour, 256, corresponds to the number of bits used to hold the +colour channel value, which is eight (28=256). Since we have +three channels with 8 bits for each (8+8+8=24), this is called 24-bit +colour depth.

+

Any particular colour in the RGB model can be expressed by a triplet +of integers in [0, 255], representing the red, green, and blue channels, +respectively. A larger number in a channel means that more of that +primary colour is present.

+
+
+ +
+Challenge +
+

Thinking about RGB colours (5 min)

+
+

Suppose that we represent colours as triples (r, g, b), where each of +r, g, and b is an integer in [0, 255]. What colours are represented by +each of these triples? (Try to answer these questions without reading +further.)

+
    +
  1. (255, 0, 0)
  2. +
  3. (0, 255, 0)
  4. +
  5. (0, 0, 255)
  6. +
  7. (255, 255, 255)
  8. +
  9. (0, 0, 0)
  10. +
  11. (128, 128, 128)
  12. +
+
+
+
+
+
+ +
+
+
    +
  1. (255, 0, 0) represents red, because the red channel is maximised, +while the other two channels have the minimum values.
  2. +
  3. (0, 255, 0) represents green.
  4. +
  5. (0, 0, 255) represents blue.
  6. +
  7. (255, 255, 255) is a little harder. When we mix the maximum value of +all three colour channels, we see the colour white.
  8. +
  9. (0, 0, 0) represents the absence of all colour, or black.
  10. +
  11. (128, 128, 128) represents a medium shade of gray. Note that the +24-bit RGB colour model provides at least 254 shades of gray, rather +than only fifty.
  12. +
+

Note that the RGB colour model may run contrary to your experience, +especially if you have mixed primary colours of paint to create new +colours. In the RGB model, the lack of any colour is black, +while the maximum amount of each of the primary colours is +white. With physical paint, we might start with a white base, and then +add differing amounts of other paints to produce a darker shade.

+
+
+
+
+

After completing the previous challenge, we can look at some further +examples of 24-bit RGB colours, in a visual way. The image in the next +challenge shows some colour names, their 24-bit RGB triplet values, and +the colour itself.

+
+
+ +
+Challenge +
+

RGB colour table (optional, not included in +timing)

+
+
RGB colour table

We cannot really provide a complete table. To see why, answer this +question: How many possible colours can be represented with the 24-bit +RGB model?

+
+
+
+
+
+ +
+
+

There are 24 total bits in an RGB colour of this type, and each bit +can be on or off, and so there are 224 = 16,777,216 possible +colours with our additive, 24-bit RGB colour model.

+
+
+
+
+

Although 24-bit colour depth is common, there are other options. For +example, we might have 8-bit colour (3 bits for red and green, but only +2 for blue, providing 8 × 8 × 4 = 256 colours) or 16-bit colour (4 bits +for red, green, and blue, plus 4 more for transparency, providing 16 × +16 × 16 = 4096 colours, with 16 transparency levels each). There are +colour depths with more than eight bits per channel, but as the human +eye can only discern approximately 10 million different colours, these +are not often used.

+

If you are using an older or inexpensive laptop screen or LCD monitor +to view images, it may only support 18-bit colour, capable of displaying +64 × 64 × 64 = 262,144 colours. 24-bit colour images will be converted +in some manner to 18-bit, and thus the colour quality you see will not +match what is actually in the image.

+

We can combine our coordinate system with the 24-bit RGB colour model +to gain a conceptual understanding of the images we will be working +with. An image is a rectangular array of pixels, each with its own +coordinate. Each pixel in the image is a square point of coloured light, +where the colour is specified by a 24-bit RGB triplet. Such an image is +an example of raster graphics.

+

Image formats +

+
+

Although the images we will manipulate in our programs are +conceptualised as rectangular arrays of RGB triplets, they are not +necessarily created, stored, or transmitted in that format. There are +several image formats we might encounter, and we should know the basics +of at least of few of them. Some formats we might encounter, and their +file extensions, are shown in this table:

+ + + + + + + + + + + + + + + + + + + +
FormatExtension
Device-Independent Bitmap (BMP).bmp
Joint Photographic Experts Group (JPEG).jpg or .jpeg
Tagged Image File Format (TIFF).tif or .tiff

BMP +

+
+

The file format that comes closest to our preceding conceptualisation +of images is the Device-Independent Bitmap, or BMP, file format. BMP +files store raster graphics images as long sequences of binary-encoded +numbers that specify the colour of each pixel in the image. Since +computer files are one-dimensional structures, the pixel colours are +stored one row at a time. That is, the first row of pixels (those with +y-coordinate 0) are stored first, followed by the second row (those with +y-coordinate 1), and so on. Depending on how it was created, a BMP image +might have 8-bit, 16-bit, or 24-bit colour depth.

+

24-bit BMP images have a relatively simple file format, can be viewed +and loaded across a wide variety of operating systems, and have high +quality. However, BMP images are not compressed, resulting in +very large file sizes for any useful image resolutions.

+

The idea of image compression is important to us for two reasons: +first, compressed images have smaller file sizes, and are therefore +easier to store and transmit; and second, compressed images may not have +as much detail as their uncompressed counterparts, and so our programs +may not be able to detect some important aspect if we are working with +compressed images. Since compression is important to us, we should take +a brief detour and discuss the concept.

+

Image compression +

+
+

Before discussing additional formats, familiarity with image +compression will be helpful. Let’s delve into that subject with a +challenge. For this challenge, you will need to know about bits / bytes +and how those are used to express computer storage capacities. If you +already know, you can skip to the challenge below.

+
+
+ +
+Callout +
+

Bits and bytes

+
+

Before we talk specifically about images, we first need to understand +how numbers are stored in a modern digital computer. When we think of a +number, we do so using a decimal, or base-10 +place-value number system. For example, a number like 659 is 6 × +102 + 5 × 101 + 9 × 100. Each digit in +the number is multiplied by a power of 10, based on where it occurs, and +there are 10 digits that can occur in each position (0, 1, 2, 3, 4, 5, +6, 7, 8, 9).

+

In principle, computers could be constructed to represent numbers in +exactly the same way. But, the electronic circuits inside a computer are +much easier to construct if we restrict the numeric base to only two, +instead of 10. (It is easier for circuitry to tell the difference +between two voltage levels than it is to differentiate among 10 levels.) +So, values in a computer are stored using a binary, or +base-2 place-value number system.

+

In this system, each symbol in a number is called a bit +instead of a digit, and there are only two values for each bit (0 and +1). We might imagine a four-bit binary number, 1101. Using the same kind +of place-value expansion as we did above for 659, we see that 1101 = 1 × +23 + 1 × 22 + 0 × 21 + 1 × +20, which if we do the math is 8 + 4 + 0 + 1, or 13 in +decimal.

+

Internally, computers have a minimum number of bits that they work +with at a given time: eight. A group of eight bits is called a +byte. The amount of memory (RAM) and drive space our computers +have is quantified by terms like Megabytes (MB), Gigabytes (GB), and +Terabytes (TB). The following table provides more formal definitions for +these terms.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
UnitAbbreviationSize
KilobyteKB1024 bytes
MegabyteMB1024 KB
GigabyteGB1024 MB
TerabyteTB1024 GB
+
+
+
+
+
+ +
+Challenge +
+

BMP image size (optional, not included in +timing)

+
+

Imagine that we have a fairly large, but very boring image: a 5,000 × +5,000 pixel image composed of nothing but white pixels. If we used an +uncompressed image format such as BMP, with the 24-bit RGB colour model, +how much storage would be required for the file?

+
+
+
+
+
+ +
+
+

In such an image, there are 5,000 × 5,000 = 25,000,000 pixels, and 24 +bits for each pixel, leading to 25,000,000 × 24 = 600,000,000 bits, or +75,000,000 bytes (71.5MB). That is quite a lot of space for a very +uninteresting image!

+
+
+
+
+

Since image files can be very large, various compression +schemes exist for saving (approximately) the same information while +using less space. These compression techniques can be categorised as +lossless or lossy.

+
+

Lossless compression +

+

In lossless image compression, we apply some algorithm (i.e., a +computerised procedure) to the image, resulting in a file that is +significantly smaller than the uncompressed BMP file equivalent would +be. Then, when we wish to load and view or process the image, our +program reads the compressed file, and reverses the compression process, +resulting in an image that is identical to the original. +Nothing is lost in the process – hence the term “lossless.”

+

The general idea of lossless compression is to somehow detect long +patterns of bytes in a file that are repeated over and over, and then +assign a smaller bit pattern to represent the longer sample. Then, the +compressed file is made up of the smaller patterns, rather than the +larger ones, thus reducing the number of bytes required to save the +file. The compressed file also contains a table of the substituted +patterns and the originals, so when the file is decompressed it can be +made identical to the original before compression.

+

To provide you with a concrete example, consider the 71.5 MB white +BMP image discussed above. When put through the zip compression utility +on Microsoft Windows, the resulting .zip file is only 72 KB in size! +That is, the .zip version of the image is three orders of magnitude +smaller than the original, and it can be decompressed into a file that +is byte-for-byte the same as the original. Since the original is so +repetitious - simply the same colour triplet repeated 25,000,000 times - +the compression algorithm can dramatically reduce the size of the +file.

+

If you work with .zip or .gz archives, you are dealing with lossless +compression.

+
+
+

Lossy compression +

+

Lossy compression takes the original image and discards some of the +detail in it, resulting in a smaller file format. The goal is to only +throw away detail that someone viewing the image would not notice. Many +lossy compression schemes have adjustable levels of compression, so that +the image creator can choose the amount of detail that is lost. The more +detail that is sacrificed, the smaller the image files will be - but of +course, the detail and richness of the image will be lower as well.

+

This is probably fine for images that are shown on Web pages or +printed off on 4 × 6 photo paper, but may or may not be fine for +scientific work. You will have to decide whether the loss of image +quality and detail are important to your work, versus the space savings +afforded by a lossy compression format.

+

It is important to understand that once an image is saved in a lossy +compression format, the lost detail is just that - lost. I.e., unlike +lossless formats, given an image saved in a lossy format, there is no +way to reconstruct the original image in a byte-by-byte manner.

+
+

JPEG +

+
+

JPEG images are perhaps the most commonly encountered digital images +today. JPEG uses lossy compression, and the degree of compression can be +tuned to your liking. It supports 24-bit colour depth, and since the +format is so widely used, JPEG images can be viewed and manipulated +easily on all computing platforms.

+
+
+ +
+Challenge +
+

Examining actual image sizes (optional, not +included in timing)

+
+

Let us see the effects of image compression on image size with actual +images. The following script creates a square white image 5000 x 5000 +pixels, and then saves it as a BMP and as a JPEG image.

+
+

PYTHON +

+
dim = 5000
+
+img = np.zeros((dim, dim, 3), dtype="uint8")
+img.fill(255)
+
+iio.imwrite(uri="data/ws.bmp", image=img)
+iio.imwrite(uri="data/ws.jpg", image=img)
+
+

Examine the file sizes of the two output files, ws.bmp +and ws.jpg. Does the BMP image size match our previous +prediction? How about the JPEG?

+
+
+
+
+
+ +
+
+

The BMP file, ws.bmp, is 75,000,054 bytes, which matches +our prediction very nicely. The JPEG file, ws.jpg, is +392,503 bytes, two orders of magnitude smaller than the bitmap +version.

+
+
+
+
+
+
+ +
+Challenge +
+

Comparing lossless versus lossy compression +(optional, not included in timing)

+
+

Let us see a hands-on example of lossless versus lossy compression. +Open a terminal (or Windows PowerShell) and navigate to the +data/ directory. The two output images, ws.bmp +and ws.jpg, should still be in the directory, along with +another image, tree.jpg.

+

We can apply lossless compression to any file by using the +zip command. Recall that the ws.bmp file +contains 75,000,054 bytes. Apply lossless compression to this image by +executing the following command: zip ws.zip ws.bmp +(Compress-Archive ws.bmp ws.zip with PowerShell). This +command tells the computer to create a new compressed file, +ws.zip, from the original bitmap image. Execute a similar +command on the tree JPEG file: zip tree.zip tree.jpg +(Compress-Archive tree.jpg tree.zip with PowerShell).

+

Having created the compressed file, use the ls -l +command (dir with PowerShell) to display the contents of +the directory. How big are the compressed files? How do those compare to +the size of ws.bmp and tree.jpg? What can you +conclude from the relative sizes?

+
+
+
+
+
+ +
+
+

Here is a partial directory listing, showing the sizes of the +relevant files there:

+
+

OUTPUT +

+
-rw-rw-r--  1 diva diva   154344 Jun 18 08:32 tree.jpg
+-rw-rw-r--  1 diva diva   146049 Jun 18 08:53 tree.zip
+-rw-rw-r--  1 diva diva 75000054 Jun 18 08:51 ws.bmp
+-rw-rw-r--  1 diva diva    72986 Jun 18 08:53 ws.zip
+
+

We can see that the regularity of the bitmap image (remember, it is a +5,000 x 5,000 pixel image containing only white pixels) allows the +lossless compression scheme to compress the file quite effectively. On +the other hand, compressing tree.jpg does not create a much +smaller file; this is because the JPEG image was already in a compressed +format.

+
+
+
+
+

Here is an example showing how JPEG compression might impact image +quality. Consider this image of several maize seedlings (scaled down +here from 11,339 × 11,336 pixels in order to fit the display).

+
Original image

Now, let us zoom in and look at a small section of the label in the +original, first in the uncompressed format:

+
Enlarged, uncompressed

Here is the same area of the image, but in JPEG format. We used a +fairly aggressive compression parameter to make the JPEG, in order to +illustrate the problems you might encounter with the format.

+
Enlarged, compressed

The JPEG image is of clearly inferior quality. It has less colour +variation and noticeable pixelation. Quality differences become even +more marked when one examines the colour histograms for each image. A +histogram shows how often each colour value appears in an image. The +histograms for the uncompressed (left) and compressed (right) images are +shown below:

+
Uncompressed histogram

We learn how to make histograms such as these later on in the +workshop. The differences in the colour histograms are even more +apparent than in the images themselves; clearly the colours in the JPEG +image are different from the uncompressed version.

+

If the quality settings for your JPEG images are high (and the +compression rate therefore relatively low), the images may be of +sufficient quality for your work. It all depends on how much quality you +need, and what restrictions you have on image storage space. Another +consideration may be where the images are stored. For example, +if your images are stored in the cloud and therefore must be downloaded +to your system before you use them, you may wish to use a compressed +image format to speed up file transfer time.

+

PNG +

+
+

PNG images are well suited for storing diagrams. It uses a lossless +compression and is hence often used in web applications for +non-photographic images. The format is able to store RGB and plain +luminance (single channel, without an associated color) data, among +others. Image data is stored row-wise and then, per row, a simple +filter, like taking the difference of adjacent pixels, can be applied to +increase the compressability of the data. The filtered data is then +compressed in the next step and written out to the disk.

+

TIFF +

+
+

TIFF images are popular with publishers, graphics designers, and +photographers. TIFF images can be uncompressed, or compressed using +either lossless or lossy compression schemes, depending on the settings +used, and so TIFF images seem to have the benefits of both the BMP and +JPEG formats. The main disadvantage of TIFF images (other than the size +of images in the uncompressed version of the format) is that they are +not universally readable by image viewing and manipulation software.

+

Metadata +

+
+

JPEG and TIFF images support the inclusion of metadata in +images. Metadata is textual information that is contained within an +image file. Metadata holds information about the image itself, such as +when the image was captured, where it was captured, what type of camera +was used and with what settings, etc. We normally don’t see this +metadata when we view an image, but we can view it independently if we +wish to (see Accessing +Metadata, below). The important thing to be aware of at this +stage is that you cannot rely on the metadata of an image being fully +preserved when you use software to process that image. The image +reader/writer library that we use throughout this lesson, +imageio.v3, includes metadata when saving new images but +may fail to keep certain metadata fields. In any case, remember: +if metadata is important to you, take precautions to always +preserve the original files.

+
+
+ +
+Callout +
+

Accessing Metadata

+
+

imageio.v3 provides a way to display or explore the +metadata associated with an image. Metadata is served independently from +pixel data:

+
+

PYTHON +

+
# read metadata
+metadata = iio.immeta(uri="data/eight.tif")
+# display the format-specific metadata
+metadata
+
+
+

OUTPUT +

+
{'is_fluoview': False,
+ 'is_nih': False,
+ 'is_micromanager': False,
+ 'is_ome': False,
+ 'is_lsm': False,
+ 'is_reduced': False,
+ 'is_shaped': True,
+ 'is_stk': False,
+ 'is_tiled': False,
+ 'is_mdgel': False,
+ 'compression': <COMPRESSION.NONE: 1>,
+ 'predictor': 1,
+ 'is_mediacy': False,
+ 'description': '{"shape": [5, 3]}',
+ 'description1': '',
+ 'is_imagej': False,
+ 'software': 'tifffile.py',
+ 'resolution_unit': 1,
+ 'resolution': (1.0, 1.0, 'NONE')}
+
+

Many popular image editing programs have built-in metadata viewing +capabilities. A platform-independent open-source tool that allows users +to read, write, and edit metadata is ExifTool. It can handle a wide range of +file types and metadata formats but requires some technical knowledge to +be used effectively. Other software exists that can help you handle +metadata, e.g., Fiji and ImageMagick. You may want +to explore these options if you need to work with the metadata of your +images.

+
+
+
+

Summary of image formats used in this lesson +

+
+

The following table summarises the characteristics of the BMP, JPEG, +and TIFF image formats:

+ +++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FormatCompressionMetadataAdvantagesDisadvantages
BMPNoneNoneUniversally viewable, high qualityLarge file sizes
JPEGLossyYesUniversally viewable, smaller file sizeDetail may be lost
PNGLosslessYesUniversally viewable, open standard, smaller file +sizeMetadata less flexible than TIFF, RGB only
TIFFNone, lossy, or losslessYesHigh quality or smaller file sizeNot universally viewable
+
+
+ +
+Key Points +
+
+
    +
  • Digital images are represented as rectangular arrays of square +pixels.
  • +
  • Digital images use a left-hand coordinate system, with the origin in +the upper left corner, the x-axis running to the right, and the y-axis +running down. Some learners may prefer to think in terms of counting +down rows for the y-axis and across columns for the x-axis. Thus, we +will make an effort to allow for both approaches in our lesson +presentation.
  • +
  • Most frequently, digital images use an additive RGB model, with +eight bits for the red, green, and blue channels.
  • +
  • scikit-image images are stored as multi-dimensional NumPy +arrays.
  • +
  • In scikit-image images, the red channel is specified first, then the +green, then the blue, i.e., RGB.
  • +
  • Lossless compression retains all the details in an image, but lossy +compression results in loss of some of the original image detail.
  • +
  • BMP images are uncompressed, meaning they have high quality but also +that their file sizes are large.
  • +
  • JPEG images use lossy compression, meaning that their file sizes are +smaller, but image quality may suffer.
  • +
  • TIFF images can be uncompressed or compressed with lossy or lossless +compression.
  • +
  • Depending on the camera or sensor, various useful pieces of +information may be stored in an image file, in the image metadata.
  • +
+
+
+
+

Content from Working with scikit-image

+
+

Last updated on 2026-03-20 | + + Edit this page

+
+ +
+
+

Overview

+
+
+
+
+

Questions

+
    +
  • How can the scikit-image Python computer vision library be used to +work with images?
  • +
+
+
+
+
+
+
+

Objectives

+
    +
  • Read and save images with imageio.
  • +
  • Display images with Matplotlib.
  • +
  • Resize images with scikit-image.
  • +
  • Perform simple image thresholding with NumPy array operations.
  • +
  • Extract sub-images using array slicing.
  • +
+
+
+
+
+
+

We have covered much of how images are represented in computer +software. In this episode we will learn some more methods for accessing +and changing digital images.

+

First, import the packages needed for this episode +

+
+
+

PYTHON +

+
import imageio.v3 as iio
+import ipympl
+import matplotlib.pyplot as plt
+import numpy as np
+import skimage as ski
+
+%matplotlib widget
+
+

Reading, displaying, and saving images +

+
+

Imageio provides intuitive functions for reading and writing (saving) +images. All of the popular image formats, such as BMP, PNG, JPEG, and +TIFF are supported, along with several more esoteric formats. Check the +Supported +Formats docs for a list of all formats. Matplotlib provides a large +collection of plotting utilities.

+

Let us examine a simple Python program to load, display, and save an +image to a different format. Here are the first few lines:

+
+

PYTHON +

+
"""Python program to open, display, and save an image."""
+# read image
+chair = iio.imread(uri="data/chair.jpg")
+
+

We use the iio.imread() function to read a JPEG image +entitled chair.jpg. Imageio reads the image, converts +it from JPEG into a NumPy array, and returns the array; we save the +array in a variable named chair.

+

Next, we will do something with the image:

+
+

PYTHON +

+
fig, ax = plt.subplots()
+ax.imshow(chair)
+
+

Once we have the image in the program, we first call +fig, ax = plt.subplots() so that we will have a fresh +figure with a set of axes independent from our previous calls. Next we +call ax.imshow() in order to display the image.

+

Now, we will save the image in another format:

+
+

PYTHON +

+
# save a new version in .tif format
+iio.imwrite(uri="data/chair.tif", image=chair)
+
+

The final statement in the program, +iio.imwrite(uri="data/chair.tif", image=chair), writes the +image to a file named chair.tif in the data/ +directory. The imwrite() function automatically determines +the type of the file, based on the file extension we provide. In this +case, the .tif extension causes the image to be saved as a +TIFF.

+
+
+ +
+Callout +
+

Metadata, revisited

+
+

Remember, as mentioned in the previous section, images saved with +imwrite() will not retain all metadata associated with the +original image that was loaded into Python! If the image metadata +is important to you, be sure to always keep an unchanged copy of +the original image!

+
+
+
+
+
+ +
+Callout +
+

Extensions do not always dictate file +type

+
+

The iio.imwrite() function automatically uses the file +type we specify in the file name parameter’s extension. Note that this +is not always the case. For example, if we are editing a document in +Microsoft Word, and we save the document as paper.pdf +instead of paper.docx, the file is not saved as a +PDF document.

+
+
+
+
+
+ +
+Callout +
+

Named versus positional arguments

+
+

When we call functions in Python, there are two ways we can specify +the necessary arguments. We can specify the arguments +positionally, i.e., in the order the parameters appear in the +function definition, or we can use named arguments.

+

For example, the iio.imwrite() function +definition specifies two parameters, the resource to save the image +to (e.g., a file name, an http address) and the image to write to disk. +So, we could save the chair image in the sample code above using +positional arguments like this:

+

iio.imwrite("data/chair.tif", image)

+

Since the function expects the first argument to be the file name, +there is no confusion about what "data/chair.jpg" means. +The same goes for the second argument.

+

The style we will use in this workshop is to name each argument, like +this:

+

iio.imwrite(uri="data/chair.tif", image=image)

+

This style will make it easier for you to learn how to use the +variety of functions we will cover in this workshop.

+
+
+
+
+
+ +
+Challenge +
+

Resizing an image (10 min)

+
+

Using the chair.jpg image located in the data folder, +write a Python script to read your image into a variable named +chair. Then, resize the image to 10 percent of its current +size using these lines of code:

+
+

PYTHON +

+
new_shape = (chair.shape[0] // 10, chair.shape[1] // 10, chair.shape[2])
+resized_chair = ski.transform.resize(image=chair, output_shape=new_shape)
+resized_chair = ski.util.img_as_ubyte(resized_chair)
+
+

As it is used here, the parameters to the +ski.transform.resize() function are the image to transform, +chair, the dimensions we want the new image to have, +new_shape.

+
+
+ +
+Callout +
+
+

Note that the pixel values in the new image are an approximation of +the original values and should not be confused with actual, observed +data. This is because scikit-image interpolates the pixel values when +reducing or increasing the size of an image. +ski.transform.resize has a number of optional parameters +that allow the user to control this interpolation. You can find more +details in the scikit-image +documentation.

+
+
+
+

Image files on disk are normally stored as whole numbers for space +efficiency, but transformations and other math operations often result +in conversion to floating point numbers. Using the +ski.util.img_as_ubyte() method converts it back to whole +numbers before we save it back to disk. If we don’t convert it before +saving, iio.imwrite() may not recognise it as image +data.

+

Next, write the resized image out to a new file named +resized.jpg in your data directory. Finally, use +ax.imshow() with each of your image variables to display +both images in your notebook. Don’t forget to use +fig, ax = plt.subplots() so you don’t overwrite the first +image with the second. Images may appear the same size in jupyter, but +you can see the size difference by comparing the scales for each. You +can also see the difference in file storage size on disk by hovering +your mouse cursor over the original and the new files in the Jupyter +file browser, using ls -l in your shell (dir +with Windows PowerShell), or viewing file sizes in the OS file browser +if it is configured so.

+
+
+
+
+
+ +
+
+

Here is what your Python script might look like.

+
+

PYTHON +

+
"""Python script to read an image, resize it, and save it under a different name."""
+
+# read in image
+chair = iio.imread(uri="data/chair.jpg")
+
+# resize the image
+new_shape = (chair.shape[0] // 10, chair.shape[1] // 10, chair.shape[2])
+resized_chair = ski.transform.resize(image=chair, output_shape=new_shape)
+resized_chair = ski.util.img_as_ubyte(resized_chair)
+
+# write out image
+iio.imwrite(uri="data/resized_chair.jpg", image=resized_chair)
+
+# display images
+fig, ax = plt.subplots()
+ax.imshow(chair)
+fig, ax = plt.subplots()
+ax.imshow(resized_chair)
+
+

The script resizes the data/chair.jpg image by a factor +of 10 in both dimensions, saves the result to the +data/resized_chair.jpg file, and displays original and +resized for comparision.

+
+
+
+
+

Manipulating pixels +

+
+

In the Image Basics +episode, we individually manipulated the colours of pixels by +changing the numbers stored in the image’s NumPy array. Let’s apply the +principles learned there along with some new principles to a real world +example.

+

Suppose we are interested in this maize root cluster image. We want +to be able to focus our program’s attention on the roots themselves, +while ignoring the black background.

+
Root cluster image

Since the image is stored as an array of numbers, we can simply look +through the array for pixel colour values that are less than some +threshold value. This process is called thresholding, and we +will see more powerful methods to perform the thresholding task in the Thresholding episode. Here, +though, we will look at a simple and elegant NumPy method for +thresholding. Let us develop a program that keeps only the pixel colour +values in an image that have value greater than or equal to 128. This +will keep the pixels that are brighter than half of “full brightness”, +i.e., pixels that do not belong to the black background.

+

We will start by reading the image and displaying it.

+
+
+ +
+Callout +
+

Loading images with imageio: Read-only +arrays

+
+

When loading an image with imageio, in certain situations the image +is stored in a read-only array. If you attempt to manipulate the pixels +in a read-only array, you will receive an error message +ValueError: assignment destination is read-only. In order +to make the image array writeable, we can create a copy with +image = np.array(image) before manipulating the pixel +values.

+
+
+
+
+

PYTHON +

+
"""Python script to ignore low intensity pixels in an image."""
+
+# read input image
+maize_roots = iio.imread(uri="data/maize-root-cluster.jpg")
+maize_roots = np.array(maize_roots)
+
+# display original image
+fig, ax = plt.subplots()
+ax.imshow(maize_roots)
+
+

Now we can threshold the image and display the result.

+
+

PYTHON +

+
# keep only high-intensity pixels
+maize_roots[maize_roots < 128] = 0
+
+# display modified image
+fig, ax = plt.subplots()
+ax.imshow(maize_roots)
+
+

The NumPy command to ignore all low-intensity pixels is +roots[roots < 128] = 0. Every pixel colour value in the +whole 3-dimensional array with a value less that 128 is set to zero. In +this case, the result is an image in which the extraneous background +detail has been removed.

+
Thresholded root image

Converting colour images to grayscale +

+
+

It is often easier to work with grayscale images, which have a single +channel, instead of colour images, which have three channels. +scikit-image offers the function ski.color.rgb2gray() to +achieve this. This function adds up the three colour channels in a way +that matches human colour perception, see the +scikit-image documentation for details. It returns a grayscale image +with floating point values in the range from 0 to 1. We can use the +function ski.util.img_as_ubyte() in order to convert it +back to the original data type and the data range back 0 to 255. Note +that it is often better to use image values represented by floating +point values, because using floating point numbers is numerically more +stable.

+
+
+ +
+Callout +
+

Colour and color +

+
+

The Carpentries generally prefers UK English spelling, which is why +we use “colour” in the explanatory text of this lesson. However, +scikit-image contains many modules and functions that include the US +English spelling, color. The exact spelling matters here, +e.g. you will encounter an error if you try to run +ski.colour.rgb2gray(). To account for this, we will use the +US English spelling, color, in example Python code +throughout the lesson. You will encounter a similar approach with +“centre” and center.

+
+
+
+
+

PYTHON +

+
"""Python script to load a color image as grayscale."""
+
+# read input image
+chair = iio.imread(uri="data/chair.jpg")
+
+# display original image
+fig, ax = plt.subplots()
+ax.imshow(chair)
+
+# convert to grayscale and display
+gray_chair = ski.color.rgb2gray(chair)
+fig, ax = plt.subplots()
+ax.imshow(gray_chair, cmap="gray")
+
+

We can also load colour images as grayscale directly by passing the +argument mode="L" to iio.imread().

+
+

PYTHON +

+
"""Python script to load a color image as grayscale."""
+
+# read input image, based on filename parameter
+gray_chair = iio.imread(uri="data/chair.jpg", mode="L")
+
+# display grayscale image
+fig, ax = plt.subplots()
+ax.imshow(gray_chair, cmap="gray")
+
+

The first argument to iio.imread() is the filename of +the image. The second argument mode="L" determines the type +and range of the pixel values in the image (e.g., an 8-bit pixel has a +range of 0-255). This argument is forwarded to the pillow +backend, a Python imaging library for which mode “L” means 8-bit pixels +and single-channel (i.e., grayscale). The backend used by +iio.imread() may be specified as an optional argument: to +use pillow, you would pass plugin="pillow". If +the backend is not specified explicitly, iio.imread() +determines the backend to use based on the image type.

+
+
+ +
+Callout +
+

Loading images with imageio: Pixel type and +depth

+
+

When loading an image with mode="L", the pixel values +are stored as 8-bit integer numbers that can take values in the range +0-255. However, pixel values may also be stored with other types and +ranges. For example, some scikit-image functions return the pixel values +as floating point numbers in the range 0-1. The type and range of the +pixel values are important for the colorscale when plotting, and for +masking and thresholding images as we will see later in the lesson. If +you are unsure about the type of the pixel values, you can inspect it +with print(image.dtype). For the example above, you should +find that it is dtype('uint8') indicating 8-bit integer +numbers.

+
+
+
+
+
+ +
+Challenge +
+

Keeping only low intensity pixels (10 +min)

+
+

A little earlier, we showed how we could use Python and scikit-image +to turn on only the high intensity pixels from an image, while turning +all the low intensity pixels off. Now, you can practice doing the +opposite - keeping all the low intensity pixels while changing the high +intensity ones.

+

The file data/sudoku.png is an RGB image of a sudoku +puzzle:

+
Su-Do-Ku puzzle

Your task is to load the image in grayscale format and turn all of +the bright pixels in the image to a light gray colour. In other words, +mask the bright pixels that have a pixel value greater than, say, 192 +and set their value to 192 (the value 192 is chosen here because it +corresponds to 75% of the range 0-255 of an 8-bit pixel). The results +should look like this:

+
Modified Su-Do-Ku puzzle

Hint: the cmap, vmin, and +vmax parameters of matplotlib.pyplot.imshow +will be needed to display the modified image as desired. See the Matplotlib +documentation for more details on cmap, +vmin, and vmax.

+
+
+
+
+
+ +
+
+

First, load the image file data/sudoku.png as a +grayscale image. Note we may want to create a copy of the image array to +avoid modifying our original variable and also because +imageio.v3.imread sometimes returns a non-writeable +image.

+
+

PYTHON +

+
sudoku = iio.imread(uri="data/sudoku.png", mode="L")
+sudoku_gray_background = np.array(sudoku)
+
+

Then change all bright pixel values greater than 192 to 192:

+
+

PYTHON +

+
sudoku_gray_background[sudoku_gray_background > 192] = 192
+
+

Finally, display the original and modified images side by side. Note +that we have to specify vmin=0 and vmax=255 as +the range of the colorscale because it would otherwise automatically +adjust to the new range 0-192.

+
+

PYTHON +

+
fig, ax = plt.subplots(ncols=2)
+ax[0].imshow(sudoku, cmap="gray", vmin=0, vmax=255)
+ax[1].imshow(sudoku_gray_background, cmap="gray", vmin=0, vmax=255)
+
+
+
+
+
+
+
+ +
+Callout +
+

Plotting single channel images (cmap, vmin, +vmax)

+
+

Compared to a colour image, a grayscale image contains only a single +intensity value per pixel. When we plot such an image with +ax.imshow, Matplotlib uses a colour map, to assign each +intensity value a colour. The default colour map is called “viridis” and +maps low values to purple and high values to yellow. We can instruct +Matplotlib to map low values to black and high values to white instead, +by calling ax.imshow with cmap="gray". The +documentation contains an overview of pre-defined colour maps.

+

Furthermore, Matplotlib determines the minimum and maximum values of +the colour map dynamically from the image, by default. That means that +in an image where the minimum is 64 and the maximum is 192, those values +will be mapped to black and white respectively (and not dark gray and +light gray as you might expect). If there are defined minimum and +maximum vales, you can specify them via vmin and +vmax to get the desired output.

+

If you forget about this, it can lead to unexpected results. Try +removing the vmax parameter from the sudoku challenge +solution and see what happens.

+
+
+
+

Access via slicing +

+
+

As noted in the previous lesson scikit-image images are stored as +NumPy arrays, so we can use array slicing to select rectangular areas of +an image. Then, we can save the selection as a new image, change the +pixels in the image, and so on. It is important to remember that +coordinates are specified in (ry, cx) order and that colour +values are specified in (r, g, b) order when doing these +manipulations.

+

Consider this image of a whiteboard, and suppose that we want to +create a sub-image with just the portion that says “odd + even = odd,” +along with the red box that is drawn around the words.

+
Whiteboard image

Using matplotlib.pyplot.imshow we can determine the +coordinates of the corners of the area we wish to extract by hovering +the mouse near the points of interest and noting the coordinates +(remember to run %matplotlib widget first if you haven’t +already). If we do that, we might settle on a rectangular area with an +upper-left coordinate of (135, 60) and a lower-right coordinate +of (480, 150), as shown in this version of the whiteboard +picture:

+
Whiteboard coordinates

Note that the coordinates in the preceding image are specified in +(cx, ry) order. Now if our entire whiteboard image is stored as +a NumPy array named image, we can create a new image of the +selected region with a statement like this:

+

clip = image[60:151, 135:481, :]

+

Our array slicing specifies the range of y-coordinates or rows first, +60:151, and then the range of x-coordinates or columns, +135:481. Note we go one beyond the maximum value in each +dimension, so that the entire desired area is selected. The third part +of the slice, :, indicates that we want all three colour +channels in our new image.

+

A script to create the subimage would start by loading the image:

+
+

PYTHON +

+
"""Python script demonstrating image modification and creation via NumPy array slicing."""
+
+# load and display original image
+board = iio.imread(uri="data/board.jpg")
+board = np.array(board)
+fig, ax = plt.subplots()
+ax.imshow(board)
+
+

Then we use array slicing to create a new image with our selected +area and then display the new image.

+
+

PYTHON +

+
# extract, display, and save sub-image
+clipped_board = board[60:151, 135:481, :]
+fig, ax = plt.subplots()
+ax.imshow(clipped_board)
+iio.imwrite(uri="data/clipped_board.tif", image=clipped_board)
+
+

We can also change the values in an image, as shown next.

+
+

PYTHON +

+
# replace clipped area with sampled color
+color = board[330, 90]
+board[60:151, 135:481] = color
+fig, ax = plt.subplots()
+ax.imshow(board)
+
+

First, we sample a single pixel’s colour at a particular location of +the image, saving it in a variable named color, which +creates a 1 × 1 × 3 NumPy array with the blue, green, and red colour +values for the pixel located at (ry = 330, cx = 90). Then, with +the img[60:151, 135:481] = color command, we modify the +image in the specified area. From a NumPy perspective, this changes all +the pixel values within that range to array saved in the +color variable. In this case, the command “erases” that +area of the whiteboard, replacing the words with a beige colour, as +shown in the final image produced by the program:

+
"Erased" whiteboard
+
+ +
+Challenge +
+

Practicing with slices (10 min - optional, not +included in timing)

+
+

Using the techniques you just learned, write a script that creates, +displays, and saves a sub-image containing only the plant and its roots +from “data/maize-root-cluster.jpg”

+
+
+
+
+
+ +
+
+

Here is the completed Python program to select only the plant and +roots in the image.

+
+

PYTHON +

+
"""Python script to extract a sub-image containing only the plant and roots in an existing image."""
+
+# load and display original image
+maize_roots = iio.imread(uri="data/maize-root-cluster.jpg")
+fig, ax = plt.subplots()
+ax.imshow(maize_roots)
+
+# extract and display sub-image
+clipped_maize = maize_roots[0:400, 275:550, :]
+fig, ax = plt.subplots()
+ax.imshow(clipped_maize)
+
+
+# save sub-image
+iio.imwrite(uri="data/clipped_maize.jpg", image=clipped_maize)
+
+
+
+
+
+
+
+ +
+Key Points +
+
+
    +
  • Images are read from disk with the iio.imread() +function.
  • +
  • We create a window that automatically scales the displayed image +with Matplotlib and calling imshow() on the global figure +object.
  • +
  • Colour images can be transformed to grayscale using +ski.color.rgb2gray() or, in many cases, be read as +grayscale directly by passing the argument mode="L" to +iio.imread().
  • +
  • We can resize images with the ski.transform.resize() +function.
  • +
  • NumPy array commands, such as +image[image < 128] = 0, can be used to manipulate the +pixels of an image.
  • +
  • Array slicing can be used to extract sub-images or modify areas of +images, e.g., clip = image[60:150, 135:480, :].
  • +
  • Metadata is not retained when images are loaded as NumPy arrays +using iio.imread().
  • +
+
+
+
+

Content from Drawing and Bitwise Operations

+
+

Last updated on 2026-03-20 | + + Edit this page

+
+ +
+
+

Overview

+
+
+
+
+

Questions

+
    +
  • How can we draw on scikit-image images and use bitwise operations +and masks to select certain parts of an image?
  • +
+
+
+
+
+
+
+

Objectives

+
    +
  • Create a blank, black scikit-image image.
  • +
  • Draw rectangles and other shapes on scikit-image images.
  • +
  • Explain how a white shape on a black background can be used as a +mask to select specific parts of an image.
  • +
  • Use bitwise operations to apply a mask to an image.
  • +
+
+
+
+
+
+

The next series of episodes covers a basic toolkit of scikit-image +operators. With these tools, we will be able to create programs to +perform simple analyses of images based on changes in colour or +shape.

+

First, import the packages needed for this episode +

+
+
+

PYTHON +

+
import imageio.v3 as iio
+import ipympl
+import matplotlib.pyplot as plt
+import numpy as np
+import skimage as ski
+
+%matplotlib widget
+
+

Here, we import the same packages as earlier in the lesson.

+

Drawing on images +

+
+

Often we wish to select only a portion of an image to analyze, and +ignore the rest. Creating a rectangular sub-image with slicing, as we +did in the Working with +scikit-image episode is one option for simple cases. Another +option is to create another special image, of the same size as the +original, with white pixels indicating the region to save and black +pixels everywhere else. Such an image is called a mask. In +preparing a mask, we sometimes need to be able to draw a shape - a +circle or a rectangle, say - on a black image. scikit-image provides +tools to do that.

+

Consider this image of maize seedlings:

+
Maize seedlings

Now, suppose we want to analyze only the area of the image containing +the roots themselves; we do not care to look at the kernels, or anything +else about the plants. Further, we wish to exclude the frame of the +container holding the seedlings as well. Hovering over the image with +our mouse, could tell us that the upper-left coordinate of the sub-area +we are interested in is (44, 357), while the lower-right +coordinate is (720, 740). These coordinates are shown in +(x, y) order.

+

A Python program to create a mask to select only that area of the +image would start with a now-familiar section of code to open and +display the original image:

+
+

PYTHON +

+
# Load and display the original image
+maize_seedlings = iio.imread(uri="data/maize-seedlings.tif")
+
+fig, ax = plt.subplots()
+ax.imshow(maize_seedlings)
+
+

We load and display the initial image in the same way we have done +before.

+

NumPy allows indexing of images/arrays with “boolean” arrays of the +same size. Indexing with a boolean array is also called mask indexing. +The “pixels” in such a mask array can only take two values: +True or False. When indexing an image with +such a mask, only pixel values at positions where the mask is +True are accessed. But first, we need to generate a mask +array of the same size as the image. Luckily, the NumPy library provides +a function to create just such an array. The next section of code shows +how:

+
+

PYTHON +

+
# Create the basic mask
+mask = np.ones(shape=maize_seedlings.shape[0:2], dtype="bool")
+
+

The first argument to the ones() function is the shape +of the original image, so that our mask will be exactly the same size as +the original. Notice, that we have only used the first two indices of +our shape. We omitted the channel dimension. Indexing with such a mask +will change all channel values simultaneously. The second argument, +dtype = "bool", indicates that the elements in the array +should be booleans - i.e., values are either True or +False. Thus, even though we use np.ones() to +create the mask, its pixel values are in fact not 1 but +True. You could check this, e.g., by +print(mask[0, 0]).

+

Next, we draw a filled, rectangle on the mask:

+
+

PYTHON +

+
# Draw filled rectangle on the mask image
+rr, cc = ski.draw.rectangle(start=(357, 44), end=(740, 720))
+mask[rr, cc] = False
+
+# Display mask image
+fig, ax = plt.subplots()
+ax.imshow(mask, cmap="gray")
+
+

Here is what our constructed mask looks like: Maize image mask

+

The parameters of the rectangle() function +(357, 44) and (740, 720), are the coordinates +of the upper-left (start) and lower-right +(end) corners of a rectangle in (ry, cx) order. +The function returns the rectangle as row (rr) and column +(cc) coordinate arrays.

+
+
+ +
+Callout +
+

Check the documentation!

+
+

When using an scikit-image function for the first time - or the fifth +time - it is wise to check how the function is used, via the scikit-image +documentation or other usage examples on programming-related sites +such as Stack Overflow. Basic +information about scikit-image functions can be found interactively in +Python, via commands like help(ski) or +help(ski.draw.rectangle). Take notes in your lab notebook. +And, it is always wise to run some test code to verify that the +functions your program uses are behaving in the manner you intend.

+
+
+
+
+
+ +
+Callout +
+

Variable naming conventions!

+
+

You may have wondered why we called the return values of the +rectangle function rr and cc?! You may have +guessed that r is short for row and +c is short for column. However, the rectangle +function returns mutiple rows and columns; thus we used a convention of +doubling the letter r to rr (and +c to cc) to indicate that those are multiple +values. In fact it may have even been clearer to name those variables +rows and columns; however this would have been +also much longer. Whatever you decide to do, try to stick to some +already existing conventions, such that it is easier for other people to +understand your code.

+
+
+
+
+
+ +
+Challenge +
+

Other drawing operations (15 min)

+
+

There are other functions for drawing on images, in addition to the +ski.draw.rectangle() function. We can draw circles, lines, +text, and other shapes as well. These drawing functions may be useful +later on, to help annotate images that our programs produce. Practice +some of these functions here.

+

Circles can be drawn with the ski.draw.disk() function, +which takes two parameters: the (ry, cx) point of the centre of the +circle, and the radius of the circle. There is an optional +shape parameter that can be supplied to this function. It +will limit the output coordinates for cases where the circle dimensions +exceed the ones of the image.

+

Lines can be drawn with the ski.draw.line() function, +which takes four parameters: the (ry, cx) coordinate of one end of the +line, and the (ry, cx) coordinate of the other end of the line.

+

Other drawing functions supported by scikit-image can be found in the +scikit-image reference pages.

+

First let’s make an empty, black image with a size of 800x600 pixels. +Recall that a colour image has three channels for the colours red, +green, and blue (RGB, cf. Image +Basics). Hence we need to create a 3D array of shape +(600, 800, 3) where the last dimension represents the RGB +colour channels.

+
+

PYTHON +

+
# create the black canvas
+canvas = np.zeros(shape=(600, 800, 3), dtype="uint8")
+
+

Now your task is to draw some other coloured shapes and lines on the +image, perhaps something like this:

+
Sample shapes
+
+
+
+
+
+ +
+
+

Drawing a circle:

+
+

PYTHON +

+
# Draw a blue circle with centre (200, 300) in (ry, cx) coordinates, and radius 100
+rr, cc = ski.draw.disk(center=(200, 300), radius=100, shape=canvas.shape[0:2])
+canvas[rr, cc] = (0, 0, 255)
+
+

Drawing a line:

+
+

PYTHON +

+
# Draw a green line from (400, 200) to (500, 700) in (ry, cx) coordinates
+rr, cc = ski.draw.line(r0=400, c0=200, r1=500, c1=700)
+canvas[rr, cc] = (0, 255, 0)
+
+
+

PYTHON +

+
# Display the image
+fig, ax = plt.subplots()
+ax.imshow(canvas)
+
+

We could expand this solution, if we wanted, to draw rectangles, +circles and lines at random positions within our black canvas. To do +this, we could use the random python module, and the +function random.randrange, which can produce random numbers +within a certain range.

+

Let’s draw 15 randomly placed circles:

+
+

PYTHON +

+
import random
+
+# create the black canvas
+canvas = np.zeros(shape=(600, 800, 3), dtype="uint8")
+
+# draw a blue circle at a random location 15 times
+for i in range(15):
+    rr, cc = ski.draw.disk(center=(
+         random.randrange(600),
+         random.randrange(800)),
+         radius=50,
+         shape=canvas.shape[0:2],
+        )
+    canvas[rr, cc] = (0, 0, 255)
+
+# display the results
+fig, ax = plt.subplots()
+ax.imshow(canvas)
+
+

We could expand this even further to also randomly choose whether to +plot a rectangle, a circle, or a square. Again, we do this with the +random module, now using the function +random.random that returns a random number between 0.0 and +1.0.

+
+

PYTHON +

+
import random
+
+# Draw 15 random shapes (rectangle, circle or line) at random positions
+for i in range(15):
+    # generate a random number between 0.0 and 1.0 and use this to decide if we
+    # want a circle, a line or a sphere
+    x = random.random()
+    if x < 0.33:
+        # draw a blue circle at a random location
+        rr, cc = ski.draw.disk(center=(
+            random.randrange(600),
+            random.randrange(800)),
+            radius=50,
+            shape=canvas.shape[0:2],
+        )
+        color = (0, 0, 255)
+    elif x < 0.66:
+        # draw a green line at a random location
+        rr, cc = ski.draw.line(
+            r0=random.randrange(600),
+            c0=random.randrange(800),
+            r1=random.randrange(600),
+            c1=random.randrange(800),
+        )
+        color = (0, 255, 0)
+    else:
+        # draw a red rectangle at a random location
+        rr, cc = ski.draw.rectangle(
+            start=(random.randrange(600), random.randrange(800)),
+            extent=(50, 50),
+            shape=canvas.shape[0:2],
+        )
+        color = (255, 0, 0)
+
+    canvas[rr, cc] = color
+
+# display the results
+fig, ax = plt.subplots()
+ax.imshow(canvas)
+
+
+
+
+
+

Image modification +

+
+

All that remains is the task of modifying the image using our mask in +such a way that the areas with True pixels in the mask are +not shown in the image any more.

+
+
+ +
+Challenge +
+

How does a mask work? (optional, not included +in timing)

+
+

Now, consider the mask image we created above. The values of the mask +that corresponds to the portion of the image we are interested in are +all False, while the values of the mask that corresponds to +the portion of the image we want to remove are all +True.

+

How do we change the original image using the mask?

+
+
+
+
+
+ +
+
+

When indexing the image using the mask, we access only those pixels +at positions where the mask is True. So, when indexing with +the mask, one can set those values to 0, and effectively remove them +from the image.

+
+
+
+
+

Now we can write a Python program to use a mask to retain only the +portions of our maize roots image that actually contains the seedling +roots. We load the original image and create the mask in the same way as +before:

+
+

PYTHON +

+
# Load the original image
+maize_seedlings = iio.imread(uri="data/maize-seedlings.tif")
+
+# Create the basic mask
+mask = np.ones(shape=maize_seedlings.shape[0:2], dtype="bool")
+
+# Draw a filled rectangle on the mask image
+rr, cc = ski.draw.rectangle(start=(357, 44), end=(740, 720))
+mask[rr, cc] = False
+
+

Then, we use NumPy indexing to remove the portions of the image, +where the mask is True:

+
+

PYTHON +

+
# Apply the mask
+maize_seedlings[mask] = 0
+
+

Then, we display the masked image.

+
+

PYTHON +

+
fig, ax = plt.subplots()
+ax.imshow(maize_seedlings)
+
+

The resulting masked image should look like this:

+
Applied mask
+
+ +
+Challenge +
+

Masking an image of your own (optional, not +included in timing)

+
+

Now, it is your turn to practice. Using your mobile phone, tablet, +webcam, or digital camera, take an image of an object with a simple +overall geometric shape (think rectangular or circular). Copy that image +to your computer, write some code to make a mask, and apply it to select +the part of the image containing your object. For example, here is an +image of a remote control:

+
Remote control image

And, here is the end result of a program masking out everything but +the remote:

+
Remote control masked
+
+
+
+
+
+ +
+
+

Here is a Python program to produce the cropped remote control image +shown above. Of course, your program should be tailored to your +image.

+
+

PYTHON +

+
# Load the image
+remote = iio.imread(uri="data/remote-control.jpg")
+remote = np.array(remote)
+
+# Create the basic mask
+mask = np.ones(shape=remote.shape[0:2], dtype="bool")
+
+# Draw a filled rectangle on the mask image
+rr, cc = ski.draw.rectangle(start=(93, 1107), end=(1821, 1668))
+mask[rr, cc] = False
+
+# Apply the mask
+remote[mask] = 0
+
+# Display the result
+fig, ax = plt.subplots()
+ax.imshow(remote)
+
+
+
+
+
+
+
+ +
+Challenge +
+

Masking a 96-well plate image (30 min)

+
+

Consider this image of a 96-well plate that has been scanned on a +flatbed scanner.

+
+

PYTHON +

+
# Load the image
+wellplate = iio.imread(uri="data/wellplate-01.jpg")
+wellplate = np.array(wellplate)
+
+# Display the image
+fig, ax = plt.subplots()
+ax.imshow(wellplate)
+
+
96-well plate

Suppose that we are interested in the colours of the solutions in +each of the wells. We do not care about the colour of the rest +of the image, i.e., the plastic that makes up the well plate itself.

+

Your task is to write some code that will produce a mask that will +mask out everything except for the wells. To help with this, you should +use the text file data/centers.txt that contains the (cx, +ry) coordinates of the centre of each of the 96 wells in this image. You +may assume that each of the wells has a radius of 16 pixels.

+

Your program should produce output that looks like this:

+
Masked 96-well plate

Hint: You can load data/centers.txt using:

+
+

PYTHON +

+
# load the well coordinates as a NumPy array
+centers = np.loadtxt("data/centers.txt", delimiter=" ")
+
+
+
+
+
+
+ +
+
+
+

PYTHON +

+
# load the well coordinates as a NumPy array
+centers = np.loadtxt("data/centers.txt", delimiter=" ")
+
+# read in original image
+wellplate = iio.imread(uri="data/wellplate-01.jpg")
+wellplate = np.array(wellplate)
+
+# create the mask image
+mask = np.ones(shape=wellplate.shape[0:2], dtype="bool")
+
+# iterate through the well coordinates
+for cx, ry in centers:
+    # draw a circle on the mask at the well center
+    rr, cc = ski.draw.disk(center=(ry, cx), radius=16, shape=wellplate.shape[:2])
+    mask[rr, cc] = False
+
+# apply the mask
+wellplate[mask] = 0
+
+# display the result
+fig, ax = plt.subplots()
+ax.imshow(wellplate)
+
+
+
+
+
+
+
+ +
+Challenge +
+

Masking a 96-well plate image, take two +(optional, not included in timing)

+
+

If you spent some time looking at the contents of the +data/centers.txt file from the previous challenge, you may +have noticed that the centres of each well in the image are very +regular. Assuming that the images are scanned in such a way +that the wells are always in the same place, and that the image is +perfectly oriented (i.e., it does not slant one way or another), we +could produce our well plate mask without having to read in the +coordinates of the centres of each well. Assume that the centre of the +upper left well in the image is at location cx = 91 and ry = 108, and +that there are 70 pixels between each centre in the cx dimension and 72 +pixels between each centre in the ry dimension. Each well still has a +radius of 16 pixels. Write a Python program that produces the same +output image as in the previous challenge, but without having +to read in the centers.txt file. Hint: use nested for +loops.

+
+
+
+
+
+ +
+
+

Here is a Python program that is able to create the masked image +without having to read in the centers.txt file.

+
+

PYTHON +

+
# read in original image
+wellplate = iio.imread(uri="data/wellplate-01.jpg")
+wellplate = np.array(wellplate)
+
+# create the mask image
+mask = np.ones(shape=wellplate.shape[0:2], dtype="bool")
+
+# upper left well coordinates
+cx0 = 91
+ry0 = 108
+
+# spaces between wells
+deltaCX = 70
+deltaRY = 72
+
+cx = cx0
+ry = ry0
+
+# iterate each row and column
+for row in range(12):
+    # reset cx to leftmost well in the row
+    cx = cx0
+    for col in range(8):
+
+        # ... and drawing a circle on the mask
+        rr, cc = ski.draw.disk(center=(ry, cx), radius=16, shape=wellplate.shape[0:2])
+        mask[rr, cc] = False
+        cx += deltaCX
+    # after one complete row, move to next row
+    ry += deltaRY
+
+# apply the mask
+wellplate[mask] = 0
+
+# display the result
+fig, ax = plt.subplots()
+ax.imshow(wellplate)
+
+
+
+
+
+
+
+ +
+Key Points +
+
+
    +
  • We can use the NumPy zeros() function to create a +blank, black image.
  • +
  • We can draw on scikit-image images with functions such as +ski.draw.rectangle(), ski.draw.disk(), +ski.draw.line(), and more.
  • +
  • The drawing functions return indices to pixels that can be set +directly.
  • +
+
+
+
+

Content from Creating Histograms

+
+

Last updated on 2026-03-20 | + + Edit this page

+
+ +
+
+

Overview

+
+
+
+
+

Questions

+
    +
  • How can we create grayscale and colour histograms to understand the +distribution of colour values in an image?
  • +
+
+
+
+
+
+
+

Objectives

+
    +
  • Explain what a histogram is.
  • +
  • Load an image in grayscale format.
  • +
  • Create and display grayscale and colour histograms for entire +images.
  • +
  • Create and display grayscale and colour histograms for certain areas +of images, via masks.
  • +
+
+
+
+
+
+

In this episode, we will learn how to use scikit-image functions to +create and display histograms for images.

+

First, import the packages needed for this episode +

+
+
+

PYTHON +

+
import imageio.v3 as iio
+import ipympl
+import matplotlib.pyplot as plt
+import numpy as np
+import skimage as ski
+
+%matplotlib widget
+
+

Introduction to Histograms +

+
+

As it pertains to images, a histogram is a graphical +representation showing how frequently various colour values occur in the +image. We saw in the Image +Basics episode that we could use a histogram to visualise the +differences in uncompressed and compressed image formats. If your +project involves detecting colour changes between images, histograms +will prove to be very useful, and histograms are also quite handy as a +preparatory step before performing thresholding.

+

Grayscale Histograms +

+
+

We will start with grayscale images, and then move on to colour +images. We will use this image of a plant seedling as an example: Plant seedling

+

Here we load the image in grayscale instead of full colour, and +display it:

+
+

PYTHON +

+
# read the image of a plant seedling as grayscale from the outset
+plant_seedling = iio.imread(uri="data/plant-seedling.jpg", mode="L")
+
+# convert the image to float dtype with a value range from 0 to 1
+plant_seedling = ski.util.img_as_float(plant_seedling)
+
+# display the image
+fig, ax = plt.subplots()
+ax.imshow(plant_seedling, cmap="gray")
+
+
Plant seedling

Again, we use the iio.imread() function to load our +image. Then, we convert the grayscale image of integer dtype, with 0-255 +range, into a floating-point one with 0-1 range, by calling the function +ski.util.img_as_float. We can also calculate histograms for +8 bit images as we will see in the subsequent exercises.

+

We now use the function np.histogram to compute the +histogram of our image which, after all, is a NumPy array:

+
+

PYTHON +

+
# create the histogram
+histogram, bin_edges = np.histogram(plant_seedling, bins=256, range=(0, 1))
+
+

The parameter bins determines the number of “bins” to +use for the histogram. We pass in 256 because we want to +see the pixel count for each of the 256 possible values in the grayscale +image.

+

The parameter range is the range of values each of the +pixels in the image can have. Here, we pass 0 and 1, which is the value +range of our input image after conversion to floating-point.

+

The first output of the np.histogram function is a +one-dimensional NumPy array, with 256 rows and one column, representing +the number of pixels with the intensity value corresponding to the +index. I.e., the first number in the array is the number of pixels found +with intensity value 0, and the final number in the array is the number +of pixels found with intensity value 255. The second output of +np.histogram is an array with the bin edges and one column +and 257 rows (one more than the histogram itself). There are no gaps +between the bins, which means that the end of the first bin, is the +start of the second and so on. For the last bin, the array also has to +contain the stop, so it has one more element, than the histogram.

+

Next, we turn our attention to displaying the histogram, by taking +advantage of the plotting facilities of the Matplotlib library.

+
+

PYTHON +

+
# configure and draw the histogram figure
+fig, ax = plt.subplots()
+ax.set_title("Grayscale Histogram")
+ax.set_xlabel("grayscale value")
+ax.set_ylabel("pixel count")
+ax.set_xlim([0.0, 1.0])  # <- named arguments do not work here
+
+ax.plot(bin_edges[0:-1], histogram)  # <- or here
+
+

We create the plot with plt.subplots(), then label the +figure and the coordinate axes with ax.set_title(), +ax.set_xlabel(), and ax.set_ylabel() +functions. The last step in the preparation of the figure is to set the +limits on the values on the x-axis with the +ax.set_xlim([0.0, 1.0]) function call.

+
+
+ +
+Callout +
+

Variable-length argument lists

+
+

Note that we cannot used named parameters for the +ax.set_xlim() or ax.plot() functions. This is +because these functions are defined to take an arbitrary number of +unnamed arguments. The designers wrote the functions this way +because they are very versatile, and creating named parameters for all +of the possible ways to use them would be complicated.

+
+
+
+

Finally, we create the histogram plot itself with +ax.plot(bin_edges[0:-1], histogram). We use the +left bin edges as x-positions for the histogram values +by indexing the bin_edges array to ignore the last value +(the right edge of the last bin). When we run the +program on this image of a plant seedling, it produces this +histogram:

+
Plant seedling histogram
+
+ +
+Callout +
+

Histograms in Matplotlib

+
+

Matplotlib provides a dedicated function to compute and display +histograms: ax.hist(). We will not use it in this lesson in +order to understand how to calculate histograms in more detail. In +practice, it is a good idea to use this function, because it visualises +histograms more appropriately than ax.plot(). Here, you +could use it by calling +ax.hist(image.flatten(), bins=256, range=(0, 1)) instead of +np.histogram() and ax.plot() +(*.flatten() is a NumPy function that converts our +two-dimensional image into a one-dimensional array).

+
+
+
+
+
+ +
+Challenge +
+

Using a mask for a histogram (15 min)

+
+

Looking at the histogram above, you will notice that there is a large +number of very dark pixels, as indicated in the chart by the spike +around the grayscale value 0.12. That is not so surprising, since the +original image is mostly black background. What if we want to focus more +closely on the leaf of the seedling? That is where a mask enters the +picture!

+

First, hover over the plant seedling image with your mouse to +determine the (x, y) coordinates of a bounding box around the +leaf of the seedling. Then, using techniques from the Drawing and Bitwise Operations +episode, create a mask with a white rectangle covering that bounding +box.

+

After you have created the mask, apply it to the input image before +passing it to the np.histogram function.

+
+
+
+
+
+ +
+
+
+

PYTHON +

+

+# read the image as grayscale from the outset
+plant_seedling = iio.imread(uri="data/plant-seedling.jpg", mode="L")
+
+# convert the image to float dtype with a value range from 0 to 1
+plant_seedling = ski.util.img_as_float(plant_seedling)
+
+# display the image
+fig, ax = plt.subplots()
+ax.imshow(plant_seedling, cmap="gray")
+
+# create mask here, using np.zeros() and ski.draw.rectangle()
+mask = np.zeros(shape=plant_seedling.shape, dtype="bool")
+rr, cc = ski.draw.rectangle(start=(199, 410), end=(384, 485))
+mask[rr, cc] = True
+
+# display the mask
+fig, ax = plt.subplots()
+ax.imshow(mask, cmap="gray")
+
+# mask the image and create the new histogram
+histogram, bin_edges = np.histogram(plant_seedling[mask], bins=256, range=(0.0, 1.0))
+
+# configure and draw the histogram figure
+fig, ax = plt.subplots()
+
+ax.set_title("Grayscale Histogram")
+ax.set_xlabel("grayscale value")
+ax.set_ylabel("pixel count")
+ax.set_xlim([0.0, 1.0])
+ax.plot(bin_edges[0:-1], histogram)
+
+

Your histogram of the masked area should look something like +this:

+
Grayscale histogram of masked area
+
+
+
+
+

Colour Histograms +

+
+

We can also create histograms for full colour images, in addition to +grayscale histograms. We have seen colour histograms before, in the Image Basics episode. A +program to create colour histograms starts in a familiar way:

+
+

PYTHON +

+
# read original image, in full color
+plant_seedling = iio.imread(uri="data/plant-seedling.jpg")
+
+# display the image
+fig, ax = plt.subplots()
+ax.imshow(plant_seedling)
+
+

We read the original image, now in full colour, and display it.

+

Next, we create the histogram, by calling the +np.histogram function three times, once for each of the +channels. We obtain the individual channels, by slicing the image along +the last axis. For example, we can obtain the red colour channel by +calling r_chan = image[:, :, 0].

+
+

PYTHON +

+
# tuple to select colors of each channel line
+colors = ("red", "green", "blue")
+
+# create the histogram plot, with three lines, one for
+# each color
+fig, ax = plt.subplots()
+ax.set_xlim([0, 256])
+for channel_id, color in enumerate(colors):
+    histogram, bin_edges = np.histogram(
+        plant_seedling[:, :, channel_id], bins=256, range=(0, 256)
+    )
+    ax.plot(bin_edges[0:-1], histogram, color=color)
+
+ax.set_title("Color Histogram")
+ax.set_xlabel("Color value")
+ax.set_ylabel("Pixel count")
+
+

We will draw the histogram line for each channel in a different +colour, and so we create a tuple of the colours to use for the three +lines with the

+

colors = ("red", "green", "blue")

+

line of code. Then, we limit the range of the x-axis with the +ax.set_xlim() function call.

+

Next, we use the for control structure to iterate +through the three channels, plotting an appropriately-coloured histogram +line for each. This may be new Python syntax for you, so we will take a +moment to discuss what is happening in the for +statement.

+

The Python built-in enumerate() function takes a list +and returns an iterator of tuples, where the first +element of the tuple is the index and the second element is the element +of the list.

+
+
+ +
+Callout +
+

Iterators, tuples, and +enumerate() +

+
+

In Python, an iterator, or an iterable object, is +something that can be iterated over with the for control +structure. A tuple is a sequence of objects, just like a list. +However, a tuple cannot be changed, and a tuple is indicated by +parentheses instead of square brackets. The enumerate() +function takes an iterable object, and returns an iterator of tuples +consisting of the 0-based index and the corresponding object.

+

For example, consider this small Python program:

+
+

PYTHON +

+
list = ("a", "b", "c", "d", "e")
+
+for x in enumerate(list):
+    print(x)
+
+

Executing this program would produce the following output:

+
+

OUTPUT +

+
(0, 'a')
+(1, 'b')
+(2, 'c')
+(3, 'd')
+(4, 'e')
+
+
+
+
+

In our colour histogram program, we are using a tuple, +(channel_id, color), as the for variable. The +first time through the loop, the channel_id variable takes +the value 0, referring to the position of the red colour +channel, and the color variable contains the string +"red". The second time through the loop the values are the +green channels index 1 and "green", and the +third time they are the blue channel index 2 and +"blue".

+

Inside the for loop, our code looks much like it did for +the grayscale example. We calculate the histogram for the current +channel with the

+

histogram, bin_edges = np.histogram(image[:, :, channel_id], bins=256, range=(0, 256))

+

function call, and then add a histogram line of the correct colour to +the plot with the

+

ax.plot(bin_edges[0:-1], histogram, color=color)

+

function call. Note the use of our loop variables, +channel_id and color.

+

Finally we label our axes and display the histogram, shown here:

+
Colour histogram
+
+ +
+Challenge +
+

Colour histogram with a mask (25 min)

+
+

We can also apply a mask to the images we apply the colour histogram +process to, in the same way we did for grayscale histograms. Consider +this image of a well plate, where various chemical sensors have been +applied to water and various concentrations of hydrochloric acid and +sodium hydroxide:

+
+

PYTHON +

+
# read the image
+wellplate = iio.imread(uri="data/wellplate-02.tif")
+
+# display the image
+fig, ax = plt.subplots()
+ax.imshow(wellplate)
+
+
Well plate image

Suppose we are interested in the colour histogram of one of the +sensors in the well plate image, specifically, the seventh well from the +left in the topmost row, which shows Erythrosin B reacting with +water.

+

Hover over the image with your mouse to find the centre of that well +and the radius (in pixels) of the well. Then create a circular mask to +select only the desired well. Then, use that mask to apply the colour +histogram operation to that well.

+

Your masked image should look like this:

+
Masked well plate

And, the program should produce a colour histogram that looks like +this:

+
Well plate histogram
+
+
+
+
+
+ +
+
+
+

PYTHON +

+
# create a circular mask to select the 7th well in the first row
+mask = np.zeros(shape=wellplate.shape[0:2], dtype="bool")
+circle = ski.draw.disk(center=(240, 1053), radius=49, shape=wellplate.shape[0:2])
+mask[circle] = 1
+
+# just for display:
+# make a copy of the image, call it masked_image, and
+# zero values where mask is False
+masked_img = np.array(wellplate)
+masked_img[~mask] = 0
+
+# create a new figure and display masked_img, to verify the
+# validity of your mask
+fig, ax = plt.subplots()
+ax.imshow(masked_img)
+
+# list to select colors of each channel line
+colors = ("red", "green", "blue")
+
+# create the histogram plot, with three lines, one for
+# each color
+fig, ax = plt.subplots()
+ax.set_xlim([0, 256])
+for (channel_id, color) in enumerate(colors):
+    # use your circular mask to apply the histogram
+    # operation to the 7th well of the first row
+    histogram, bin_edges = np.histogram(
+        wellplate[:, :, channel_id][mask], bins=256, range=(0, 256)
+    )
+
+    ax.plot(histogram, color=color)
+
+ax.set_xlabel("color value")
+ax.set_ylabel("pixel count")
+
+
+
+
+
+
+
+ +
+Key Points +
+
+
    +
  • In many cases, we can load images in grayscale by passing the +mode="L" argument to the iio.imread() +function.
  • +
  • We can create histograms of images with the +np.histogram function.
  • +
  • We can display histograms using ax.plot() with the +bin_edges and histogram values returned by +np.histogram().
  • +
  • The plot can be customised using ax.set_xlabel(), +ax.set_ylabel(), ax.set_xlim(), +ax.set_ylim(), and ax.set_title().
  • +
  • We can separate the colour channels of an RGB image using slicing +operations and create histograms for each colour channel +separately.
  • +
+
+
+
+

Content from Blurring Images

+
+

Last updated on 2026-03-20 | + + Edit this page

+
+ +
+
+

Overview

+
+
+
+
+

Questions

+
    +
  • How can we apply a low-pass blurring filter to an image?
  • +
+
+
+
+
+
+
+

Objectives

+
    +
  • Explain why applying a low-pass blurring filter to an image is +beneficial.
  • +
  • Apply a Gaussian blur filter to an image using scikit-image.
  • +
+
+
+
+
+
+

In this episode, we will learn how to use scikit-image functions to +blur images.

+

When processing an image, we are often interested in identifying +objects represented within it so that we can perform some further +analysis of these objects, e.g., by counting them, measuring their +sizes, etc. An important concept associated with the identification of +objects in an image is that of edges: the lines that represent +a transition from one group of similar pixels in the image to another +different group. One example of an edge is the pixels that represent the +boundaries of an object in an image, where the background of the image +ends and the object begins.

+

When we blur an image, we make the colour transition from one side of +an edge in the image to another smooth rather than sudden. The effect is +to average out rapid changes in pixel intensity. Blurring is a very +common operation we need to perform before other tasks such as thresholding. There are several +different blurring functions in the ski.filters module, so +we will focus on just one here, the Gaussian blur.

+
+
+ +
+Callout +
+

Filters

+
+

In the day-to-day, macroscopic world, we have physical filters which +separate out objects by size. A filter with small holes allows only +small objects through, leaving larger objects behind. This is a good +analogy for image filters. A high-pass filter will retain the smaller +details in an image, filtering out the larger ones. A low-pass filter +retains the larger features, analogous to what’s left behind by a +physical filter mesh. High- and low-pass, here, refer +to high and low spatial frequencies in the image. Details +associated with high spatial frequencies are small, a lot of these +features would fit across an image. Features associated with low spatial +frequencies are large - maybe a couple of big features per image.

+
+
+
+
+
+ +
+Callout +
+

Blurring

+
+

To blur is to make something less clear or distinct. This could be +interpreted quite broadly in the context of image analysis - anything +that reduces or distorts the detail of an image might apply. Applying a +low-pass filter, which removes detail occurring at high spatial +frequencies, is perceived as a blurring effect. A Gaussian blur is a +filter that makes use of a Gaussian kernel.

+
+
+
+
+
+ +
+Callout +
+

Kernels

+
+

A kernel can be used to implement a filter on an image. A kernel, in +this context, is a small matrix which is combined with the image using a +mathematical technique: convolution. Different sizes, shapes +and contents of kernel produce different effects. The kernel can be +thought of as a little image in itself, and will favour features of +similar size and shape in the main image. On convolution with an image, +a big, blobby kernel will retain big, blobby, low spatial frequency +features.

+
+
+
+

Gaussian blur +

+
+

Consider this image of a cat, in particular the area of the image +outlined by the white square.

+
Cat image

Now, zoom in on the area of the cat’s eye, as shown in the left-hand +image below. When we apply a filter, we consider each pixel in the +image, one at a time. In this example, the pixel we are currently +working on is highlighted in red, as shown in the right-hand image.

+
Cat eye pixels

When we apply a filter, we consider rectangular groups of pixels +surrounding each pixel in the image, in turn. The kernel is +another group of pixels (a separate matrix / small image), of the same +dimensions as the rectangular group of pixels in the image, that moves +along with the pixel being worked on by the filter. The width and height +of the kernel must be an odd number, so that the pixel being worked on +is always in its centre. In the example shown above, the kernel is +square, with a dimension of seven pixels.

+

To apply the kernel to the current pixel, an average of the colour +values of the pixels surrounding it is calculated, weighted by the +values in the kernel. In a Gaussian blur, the pixels nearest the centre +of the kernel are given more weight than those far away from the centre. +The rate at which this weight diminishes is determined by a Gaussian +function, hence the name Gaussian blur.

+

A Gaussian function maps random variables into a normal distribution +or “Bell Curve”. Gaussian function

+ +

The shape of the function is described by a mean value μ, and a +variance value σ². The mean determines the central point of the bell +curve on the X axis, and the variance describes the spread of the +curve.

+

In fact, when using Gaussian functions in Gaussian blurring, we use a +2D Gaussian function to account for X and Y dimensions, but the same +rules apply. The mean μ is always 0, and represents the middle of the 2D +kernel. Increasing values of σ² in either dimension increases the amount +of blurring in that dimension.

+
2D Gaussian function
+

The averaging is done on a channel-by-channel basis, and the average +channel values become the new value for the pixel in the filtered image. +Larger kernels have more values factored into the average, and this +implies that a larger kernel will blur the image more than a smaller +kernel.

+

To get an idea of how this works, consider this plot of the +two-dimensional Gaussian function:

+
2D Gaussian function

Imagine that plot laid over the kernel for the Gaussian blur filter. +The height of the plot corresponds to the weight given to the underlying +pixel in the kernel. I.e., the pixels close to the centre become more +important to the filtered pixel colour than the pixels close to the +outer limits of the kernel. The shape of the Gaussian function is +controlled via its standard deviation, or sigma. A large sigma value +results in a flatter shape, while a smaller sigma value results in a +more pronounced peak. The mathematics involved in the Gaussian blur +filter are not quite that simple, but this explanation gives you the +basic idea.

+

To illustrate the blurring process, consider the blue channel colour +values from the seven-by-seven region of the cat image above:

+
Image corner pixels

The filter is going to determine the new blue channel value for the +centre pixel – the one that currently has the value 86. The filter +calculates a weighted average of all the blue channel values in the +kernel giving higher weight to the pixels near the centre of the +kernel.

+
Image multiplication

This weighted average, the sum of the multiplications, becomes the +new value for the centre pixel (3, 3). The same process would be used to +determine the green and red channel values, and then the kernel would be +moved over to apply the filter to the next pixel in the image.

+ +
+
+ +
+Callout +
+

Image edges

+
+

Something different needs to happen for pixels near the outer limits +of the image, since the kernel for the filter may be partially off the +image. For example, what happens when the filter is applied to the +upper-left pixel of the image? Here are the blue channel pixel values +for the upper-left pixel of the cat image, again assuming a +seven-by-seven kernel:

+
+

OUTPUT +

+
  x   x   x   x   x   x   x
+  x   x   x   x   x   x   x
+  x   x   x   x   x   x   x
+  x   x   x   4   5   9   2
+  x   x   x   5   3   6   7
+  x   x   x   6   5   7   8
+  x   x   x   5   4   5   3
+
+

The upper-left pixel is the one with value 4. Since the pixel is at +the upper-left corner, there are no pixels underneath much of the +kernel; here, this is represented by x’s. So, what does the filter do in +that situation?

+

The default mode is to fill in the nearest pixel value from +the image. For each of the missing x’s the image value closest to the x +is used. If we fill in a few of the missing pixels, you will see how +this works:

+
+

OUTPUT +

+
  x   x   x   4   x   x   x
+  x   x   x   4   x   x   x
+  x   x   x   4   x   x   x
+  4   4   4   4   5   9   2
+  x   x   x   5   3   6   7
+  x   x   x   6   5   7   8
+  x   x   x   5   4   5   3
+
+

Another strategy to fill those missing values is to reflect +the pixels that are in the image to fill in for the pixels that are +missing from the kernel.

+
+

OUTPUT +

+
  x   x   x   5   x   x   x
+  x   x   x   6   x   x   x
+  x   x   x   5   x   x   x
+  2   9   5   4   5   9   2
+  x   x   x   5   3   6   7
+  x   x   x   6   5   7   8
+  x   x   x   5   4   5   3
+
+

A similar process would be used to fill in all of the other missing +pixels from the kernel. Other border modes are available; you +can learn more about them in the scikit-image +documentation.

+
+
+
+

Let’s consider a very simple image to see blurring in action. The +animation below shows how the blur kernel (large red square) moves along +the image on the left in order to calculate the corresponding values for +the blurred image (yellow central square) on the right. In this simple +case, the original image is single-channel, but blurring would work +likewise on a multi-channel image.

+
Blur demo animation

scikit-image has built-in functions to perform blurring for us, so we +do not have to perform all of these mathematical operations ourselves. +Let’s work through an example of blurring an image with the scikit-image +Gaussian blur function.

+

First, import the packages needed for this episode:

+
+

PYTHON +

+
import imageio.v3 as iio
+import ipympl
+import matplotlib.pyplot as plt
+import skimage as ski
+
+%matplotlib widget
+
+

Then, we load the image, and display it:

+
+

PYTHON +

+
image = iio.imread(uri="data/gaussian-original.png")
+
+# display the image
+fig, ax = plt.subplots()
+ax.imshow(image)
+
+
Original image

Next, we apply the gaussian blur:

+
+

PYTHON +

+
sigma = 3.0
+
+# apply Gaussian blur, creating a new image
+blurred = ski.filters.gaussian(
+    image, sigma=(sigma, sigma), truncate=3.5, channel_axis=-1)
+
+

The first two arguments to ski.filters.gaussian() are +the image to blur, image, and a tuple defining the sigma to +use in ry- and cx-direction, (sigma, sigma). The third +parameter truncate is meant to pass the radius of the +kernel in number of sigmas. A Gaussian function is defined from +-infinity to +infinity, but our kernel (which must have a finite, +smaller size) can only approximate the real function. Therefore, we must +choose a certain distance from the centre of the function where we stop +this approximation, and set the final size of our kernel. In the above +example, we set truncate to 3.5, which means the kernel +size will be 2 * sigma * 3.5. For example, for a sigma of +1.0 the resulting kernel size would be 7, while for a sigma +of 2.0 the kernel size would be 14. The default value for +truncate in scikit-image is 4.0.

+

The last argument we passed to ski.filters.gaussian() is +used to specify the dimension which contains the (colour) channels. +Here, it is the last dimension; recall that, in Python, the +-1 index refers to the last position. In this case, the +last dimension is the third dimension (index 2), since our +image has three dimensions:

+
+

PYTHON +

+
print(image.ndim)
+
+
+

OUTPUT +

+
3
+
+

Finally, we display the blurred image:

+
+

PYTHON +

+
# display blurred image
+fig, ax = plt.subplots()
+ax.imshow(blurred)
+
+
Blurred image

Visualising Blurring +

+
+

Somebody said once “an image is worth a thousand words”. What is +actually happening to the image pixels when we apply blurring may be +difficult to grasp. Let’s now visualise the effects of blurring from a +different perspective.

+

Let’s use the petri-dish image from previous episodes:

+
Bacteria colony
Graysacle version of the Petri dish image
+

What we want to see here is the pixel intensities from a lateral +perspective: we want to see the profile of intensities. For instance, +let’s look for the intensities of the pixels along the horizontal line +at Y=150:

+
+

PYTHON +

+
# read colonies color image and convert to grayscale
+image = iio.imread('data/colonies-01.tif')
+image_gray = ski.color.rgb2gray(image)
+
+# define the pixels for which we want to view the intensity (profile)
+xmin, xmax = (0, image_gray.shape[1])
+Y = ymin = ymax = 150
+
+# view the image indicating the profile pixels position
+fig, ax = plt.subplots()
+ax.imshow(image_gray, cmap='gray')
+ax.plot([xmin, xmax], [ymin, ymax], color='red')
+
+
Bacteria colony image with selected pixels marker
Grayscale Petri dish image marking selected +pixels for profiling
+

The intensity of those pixels we can see with a simple line plot:

+
+

PYTHON +

+
# select the vector of pixels along "Y"
+image_gray_pixels_slice = image_gray[Y, :]
+
+# guarantee the intensity values are in the [0:255] range (unsigned integers)
+image_gray_pixels_slice = ski.img_as_ubyte(image_gray_pixels_slice)
+
+fig, ax = plt.subplots()
+ax.plot(image_gray_pixels_slice, color='red')
+ax.set_ylim(255, 0)
+ax.set_ylabel('L')
+ax.set_xlabel('X')
+
+
Pixel intensities profile in original image
Intensities profile line plot of pixels along +Y=150 in original image
+

And now, how does the same set of pixels look in the corresponding +blurred image:

+
+

PYTHON +

+
# first, create a blurred version of (grayscale) image
+image_blur = ski.filters.gaussian(image_gray, sigma=3)
+
+# like before, plot the pixels profile along "Y"
+image_blur_pixels_slice = image_blur[Y, :]
+image_blur_pixels_slice = ski.img_as_ubyte(image_blur_pixels_slice)
+
+fig, ax = plt.subplots()
+ax.plot(image_blur_pixels_slice, 'red')
+ax.set_ylim(255, 0)
+ax.set_ylabel('L')
+ax.set_xlabel('X')
+
+
Pixel intensities profile in blurred image
Intensities profile of pixels along Y=150 in +blurred image
+

And that is why blurring is also called smoothing. +This is how low-pass filters affect neighbouring pixels.

+

Now that we have seen the effects of blurring an image from two +different perspectives, front and lateral, let’s take yet another look +using a 3D visualisation.

+
+
+ +
+Callout +
+

3D Plots with matplotlib

+
+

The code to generate these 3D plots is outside the scope of this +lesson but can be viewed by following the links in the captions.

+
+
+
+
3D surface plot showing pixel intensities across the whole example Petri dish image before blurring
A 3D plot of pixel intensities across the whole +Petri dish image before blurring. Explore +how this plot was created with matplotlib. Image credit: Carlos H Brandt.
+
3D surface plot illustrating the smoothing effect on pixel intensities across the whole example Petri dish image after blurring
A 3D plot of pixel intensities after Gaussian +blurring of the Petri dish image. Note the ‘smoothing’ effect on the +pixel intensities of the colonies in the image, and the ‘flattening’ of +the background noise at relatively low pixel intensities throughout the +image. Explore +how this plot was created with matplotlib. Image credit: Carlos H Brandt.
+
+
+ +
+Challenge +
+

Experimenting with sigma values (10 min)

+
+

The size and shape of the kernel used to blur an image can have a +significant effect on the result of the blurring and any downstream +analysis carried out on the blurred image. The next two exercises ask +you to experiment with the sigma values of the kernel, which is a good +way to develop your understanding of how the choice of kernel can +influence the result of blurring.

+

First, try running the code above with a range of smaller and larger +sigma values. Generally speaking, what effect does the sigma value have +on the blurred image?

+
+
+
+
+
+ +
+
+

Generally speaking, the larger the sigma value, the more blurry the +result. A larger sigma will tend to get rid of more noise in the image, +which will help for other operations we will cover soon, such as +thresholding. However, a larger sigma also tends to eliminate some of +the detail from the image. So, we must strike a balance with the sigma +value used for blur filters.

+
+
+
+
+
+
+ +
+Challenge +
+

Experimenting with kernel shape (10 min - +optional, not included in timing)

+
+

Now, what is the effect of applying an asymmetric kernel to blurring +an image? Try running the code above with different sigmas in the ry and +cx direction. For example, a sigma of 1.0 in the ry direction, and 6.0 +in the cx direction.

+
+
+
+
+
+ +
+
+
+

PYTHON +

+
# apply Gaussian blur, with a sigma of 1.0 in the ry direction, and 6.0 in the cx direction
+blurred = ski.filters.gaussian(
+    image, sigma=(1.0, 6.0), truncate=3.5, channel_axis=-1
+)
+
+# display blurred image
+fig, ax = plt.subplots()
+ax.imshow(blurred)
+
+
Rectangular kernel blurred image

These unequal sigma values produce a kernel that is rectangular +instead of square. The result is an image that is much more blurred in +the X direction than in the Y direction. For most use cases, a uniform +blurring effect is desirable and this kind of asymmetric blurring should +be avoided. However, it can be helpful in specific circumstances, e.g., +when noise is present in your image in a particular pattern or +orientation, such as vertical lines, or when you want to remove +uniform noise without blurring edges present in the image in a +particular orientation.

+
+
+
+
+

Other methods of blurring +

+
+

The Gaussian blur is a way to apply a low-pass filter in +scikit-image. It is often used to remove Gaussian (i.e., random) noise +in an image. For other kinds of noise, e.g., “salt and pepper”, a median +filter is typically used. See the +skimage.filters documentation for a list of available +filters.

+
+
+ +
+Key Points +
+
+
    +
  • Applying a low-pass blurring filter smooths edges and removes noise +from an image.
  • +
  • Blurring is often used as a first step before we perform +thresholding or edge detection.
  • +
  • The Gaussian blur can be applied to an image with the +ski.filters.gaussian() function.
  • +
  • Larger sigma values may remove more noise, but they will also remove +detail from an image.
  • +
+
+
+
+

Content from Thresholding

+
+

Last updated on 2026-04-28 | + + Edit this page

+
+ +
+
+

Overview

+
+
+
+
+

Questions

+
    +
  • How can we use thresholding to produce a binary image?
  • +
+
+
+
+
+
+
+

Objectives

+
    +
  • Explain what thresholding is and how it can be used.
  • +
  • Use histograms to determine appropriate threshold values to use for +the thresholding process.
  • +
  • Apply simple, fixed-level binary thresholding to an image.
  • +
  • Explain the difference between using the operator > +or the operator < to threshold an image represented by a +NumPy array.
  • +
  • Describe the shape of a binary image produced by thresholding via +> or <.
  • +
  • Explain when Otsu’s method for automatic thresholding is +appropriate.
  • +
  • Apply automatic thresholding to an image using Otsu’s method.
  • +
  • Use the np.count_nonzero() function to count the number +of non-zero pixels in an image.
  • +
+
+
+
+
+
+

In this episode, we will learn how to use scikit-image functions to +apply thresholding to an image. Thresholding is a type of image +segmentation, where an image is split into different regions, or +segments. These segments can then be analyzed separately.

+

In thresholding, we convert an image from colour or grayscale into a +binary image, i.e., one that is simply black and white. Most +frequently, we use thresholding as a way to select areas of interest of +an image, while ignoring the parts we are not concerned with. We have +already done some simple thresholding, in the “Manipulating pixels” +section of the Working with +scikit-image episode. In that case, we used a simple NumPy +array manipulation to separate the pixels belonging to the root system +of a plant from the black background. In this episode, we will learn how +to use scikit-image functions to perform thresholding. Then, we will use +the masks returned by these functions to select the parts of an image we +are interested in.

+

First, import the packages needed for this episode +

+
+
+

PYTHON +

+
import glob
+
+import imageio.v3 as iio
+import ipympl
+import matplotlib.pyplot as plt
+import numpy as np
+import skimage as ski
+
+%matplotlib widget
+
+

Simple thresholding +

+
+

Consider the image data/shapes-01.jpg with a series of +crudely cut shapes set against a white background.

+
+

PYTHON +

+
# load the image
+shapes01 = iio.imread(uri="data/shapes-01.jpg")
+
+fig, ax = plt.subplots()
+ax.imshow(shapes01)
+
+
Image with geometric shapes on white background

Now suppose we want to select only the shapes from the image. In +other words, we want to leave the pixels belonging to the shapes “on,” +while turning the rest of the pixels “off,” by setting their colour +channel values to zeros. The scikit-image library has several different +methods of thresholding. We will start with the simplest version, which +involves an important step of human input. Specifically, in this simple, +fixed-level thresholding, we have to provide a threshold value +t.

+

The process works like this. First, we will load the original image, +convert it to grayscale, and de-noise it as in the Blurring Images episode.

+
+

PYTHON +

+
# convert the image to grayscale
+gray_shapes = ski.color.rgb2gray(shapes01)
+
+# blur the image to denoise
+blurred_shapes = ski.filters.gaussian(gray_shapes, sigma=1.0)
+
+fig, ax = plt.subplots()
+ax.imshow(blurred_shapes, cmap="gray")
+
+
Grayscale image of the geometric shapes

Next, we would like to apply the threshold t such that +pixels with grayscale values on one side of t will be +turned “on”, while pixels with grayscale values on the other side will +be turned “off”. How might we do that? Remember that grayscale images +contain pixel values in the range from 0 to 1, so we are looking for a +threshold t in the closed range [0.0, 1.0]. We see in the +image that the geometric shapes are “darker” than the white background +but there is also some light gray noise on the background. One way to +determine a “good” value for t is to look at the grayscale +histogram of the image and try to identify what grayscale ranges +correspond to the shapes in the image or the background.

+

The histogram for the shapes image shown above can be produced as in +the Creating Histograms +episode.

+
+

PYTHON +

+
# create a histogram of the blurred grayscale image
+histogram, bin_edges = np.histogram(blurred_shapes, bins=256, range=(0.0, 1.0))
+
+fig, ax = plt.subplots()
+ax.plot(bin_edges[0:-1], histogram)
+ax.set_title("Grayscale Histogram")
+ax.set_xlabel("grayscale value")
+ax.set_ylabel("pixels")
+ax.set_xlim(0, 1.0)
+
+
Grayscale histogram of the geometric shapes image

Since the image has a white background, most of the pixels in the +image are white. This corresponds nicely to what we see in the +histogram: there is a peak near the value of 1.0. If we want to select +the shapes and not the background, we want to turn off the white +background pixels, while leaving the pixels for the shapes turned on. +So, we should choose a value of t somewhere before the +large peak and turn pixels above that value “off”. Let us choose +t=0.8.

+

To apply the threshold t, we can use the NumPy +comparison operators to create a mask. Here, we want to turn “on” all +pixels which have values smaller than the threshold, so we use the less +operator < to compare the blurred_image to +the threshold t. The operator returns a mask, that we +capture in the variable binary_mask. It has only one +channel, and each of its values is either 0 or 1. The binary mask +created by the thresholding operation can be shown with +ax.imshow, where the False entries are shown +as black pixels (0-valued) and the True entries are shown +as white pixels (1-valued).

+
+

PYTHON +

+
# create a mask based on the threshold
+t = 0.8
+binary_mask = blurred_shapes < t
+
+fig, ax = plt.subplots()
+ax.imshow(binary_mask, cmap="gray")
+
+
Binary mask of the geometric shapes created by thresholding

You can see that the areas where the shapes were in the original area +are now white, while the rest of the mask image is black.

+
+
+ +
+Callout +
+

What makes a good threshold?

+
+

As is often the case, the answer to this question is “it depends”. In +the example above, we could have just switched off all the white +background pixels by choosing t=1.0, but this would leave +us with some background noise in the mask image. On the other hand, if +we choose too low a value for the threshold, we could lose some of the +shapes that are too bright. You can experiment with the threshold by +re-running the above code lines with different values for +t. In practice, it is a matter of domain knowledge and +experience to interpret the peaks in the histogram so to determine an +appropriate threshold. The process often involves trial and error, which +is a drawback of the simple thresholding method. Below we will introduce +automatic thresholding, which uses a quantitative, mathematical +definition for a good threshold that allows us to determine the value of +t automatically. It is worth noting that the principle for +simple and automatic thresholding can also be used for images with pixel +ranges other than [0.0, 1.0]. For example, we could perform thresholding +on pixel intensity values in the range [0, 255] as we have already seen +in the Working with +scikit-image episode.

+
+
+
+

We can now apply the binary_mask to the original +coloured image as we have learned in the +Drawing and Bitwise Operations episode. What we are left +with is only the coloured shapes from the original.

+
+

PYTHON +

+
# use the binary_mask to select the "interesting" part of the image
+selection = shapes01.copy()
+selection[~binary_mask] = 0
+
+fig, ax = plt.subplots()
+ax.imshow(selection)
+
+
Selected shapes after applying binary mask
+
+ +
+Challenge +
+

More practice with simple thresholding (15 +min)

+
+

Now, it is your turn to practice. Suppose we want to use simple +thresholding to select only the coloured shapes (in this particular case +we consider grayish to be a colour, too) from the image +data/shapes-02.jpg:

+
Another image with geometric shapes on white background

First, plot the grayscale histogram as in the Creating Histogram episode and +examine the distribution of grayscale values in the image. What do you +think would be a good value for the threshold t?

+
+
+
+
+
+ +
+
+

The histogram for the data/shapes-02.jpg image can be +shown with

+
+

PYTHON +

+
shapes = iio.imread(uri="data/shapes-02.jpg")
+gray_shapes = ski.color.rgb2gray(shapes)
+histogram, bin_edges = np.histogram(gray_shapes, bins=256, range=(0.0, 1.0))
+
+fig, ax = plt.subplots()
+ax.plot(bin_edges[0:-1], histogram)
+ax.set_title("Graylevel histogram")
+ax.set_xlabel("gray value")
+ax.set_ylabel("pixel count")
+ax.set_xlim(0, 1.0)
+
+
Grayscale histogram of the second geometric shapes image

We can see a large spike around 0.3, and a smaller spike around 0.7. +The spike near 0.3 represents the darker background, so it seems like a +value close to t=0.5 would be a good choice.

+
+
+
+
+
+
+ +
+Challenge +
+

More practice with simple thresholding (15 +min) (continued) +

+
+

Next, create a mask to turn the pixels above the threshold +t on and pixels below the threshold t off. +Note that unlike the image with a white background we used above, here +the peak for the background colour is at a lower gray level than the +shapes. Therefore, change the comparison operator less < +to greater > to create the appropriate mask. Then apply +the mask to the image and view the thresholded image. If everything +works as it should, your output should show only the coloured shapes on +a black background.

+
+
+
+
+
+ +
+
+

Here are the commands to create and view the binary mask

+
+

PYTHON +

+
t = 0.5
+binary_mask = gray_shapes > t
+
+fig, ax = plt.subplots()
+ax.imshow(binary_mask, cmap="gray")
+
+
Binary mask created by thresholding the second geometric shapes image

And here are the commands to apply the mask and view the thresholded +image

+
+

PYTHON +

+
shapes02 = iio.imread(uri="data/shapes-02.jpg")
+selection = shapes02.copy()
+selection[~binary_mask] = 0
+
+fig, ax = plt.subplots()
+ax.imshow(selection)
+
+
Selected shapes after applying binary mask to the second geometric shapes image
+
+
+
+
+

Automatic thresholding +

+
+

The downside of the simple thresholding technique is that we have to +make an educated guess about the threshold t by inspecting +the histogram. There are also automatic thresholding methods +that can determine the threshold automatically for us. One such method +is Otsu’s +method. It is particularly useful for situations where the +grayscale histogram of an image has two peaks that correspond to +background and objects of interest.

+
+
+ +
+Callout +
+

Denoising an image before thresholding

+
+

In practice, it is often necessary to denoise the image before +thresholding, which can be done with one of the methods from the Blurring Images episode.

+
+
+
+

Consider the image data/maize-root-cluster.jpg of a +maize root system which we have seen before in the Working with scikit-image +episode.

+
+

PYTHON +

+
maize_roots = iio.imread(uri="data/maize-root-cluster.jpg")
+
+fig, ax = plt.subplots()
+ax.imshow(maize_roots)
+
+
Image of a maize root

We use Gaussian blur with a sigma of 1.0 to denoise the root image. +Let us look at the grayscale histogram of the denoised image.

+
+

PYTHON +

+
# convert the image to grayscale
+gray_image = ski.color.rgb2gray(maize_roots)
+
+# blur the image to denoise
+blurred_image = ski.filters.gaussian(gray_image, sigma=1.0)
+
+# show the histogram of the blurred image
+histogram, bin_edges = np.histogram(blurred_image, bins=256, range=(0.0, 1.0))
+fig, ax = plt.subplots()
+ax.plot(bin_edges[0:-1], histogram)
+ax.set_title("Graylevel histogram")
+ax.set_xlabel("gray value")
+ax.set_ylabel("pixel count")
+ax.set_xlim(0, 1.0)
+
+
Grayscale histogram of the maize root image

The histogram has a significant peak around 0.2 and then a broader +“hill” around 0.6 followed by a smaller peak near 1.0. Looking at the +grayscale image, we can identify the peak at 0.2 with the background and +the broader peak with the foreground. Thus, this image is a good +candidate for thresholding with Otsu’s method. The mathematical details +of how this works are complicated (see the +scikit-image documentation if you are interested), but the outcome +is that Otsu’s method finds a threshold value between the two peaks of a +grayscale histogram which might correspond well to the foreground and +background depending on the data and application.

+ +

The ski.filters.threshold_otsu() function can be used to +determine the threshold automatically via Otsu’s method. Then NumPy +comparison operators can be used to apply it as before. Here are the +Python commands to determine the threshold t with Otsu’s +method.

+
+

PYTHON +

+
# perform automatic thresholding
+t = ski.filters.threshold_otsu(blurred_image)
+print("Found automatic threshold t = {}.".format(t))
+
+
+

OUTPUT +

+
Found automatic threshold t = 0.4116003928683858.
+
+

For this root image and a Gaussian blur with the chosen sigma of 1.0, +the computed threshold value is 0.42. No we can create a binary mask +with the comparison operator >. As we have seen before, +pixels above the threshold value will be turned on, those below the +threshold will be turned off.

+
+

PYTHON +

+
# create a binary mask with the threshold found by Otsu's method
+binary_mask = blurred_image > t
+
+fig, ax = plt.subplots()
+ax.imshow(binary_mask, cmap="gray")
+
+
Binary mask of the maize root system

Finally, we use the mask to select the foreground:

+
+

PYTHON +

+
# apply the binary mask to select the foreground
+selection = maize_roots.copy()
+selection[~binary_mask] = 0
+
+fig, ax = plt.subplots()
+ax.imshow(selection)
+
+
Masked selection of the maize root system

Application: measuring root mass +

+
+

Let us now turn to an application where we can apply thresholding and +other techniques we have learned to this point. Consider these four +maize root system images, which you can find in the files +data/trial-016.jpg, data/trial-020.jpg, +data/trial-216.jpg, and +data/trial-293.jpg.

+
Four images of maize roots

Suppose we are interested in the amount of plant material in each +image, and in particular how that amount changes from image to image. +Perhaps the images represent the growth of the plant over time, or +perhaps the images show four different maize varieties at the same phase +of their growth. The question we would like to answer is, “how much root +mass is in each image?”

+

We will first construct a Python program to measure this value for a +single image. Our strategy will be this:

+
    +
  1. Read the image, converting it to grayscale as it is read. For this +application we do not need the colour image.
  2. +
  3. Blur the image.
  4. +
  5. Use Otsu’s method of thresholding to create a binary image, where +the pixels that were part of the maize plant are white, and everything +else is black.
  6. +
  7. Save the binary image so it can be examined later.
  8. +
  9. Count the white pixels in the binary image, and divide by the number +of pixels in the image. This ratio will be a measure of the root mass of +the plant in the image.
  10. +
  11. Output the name of the image processed and the root mass ratio.
  12. +
+

Our intent is to perform these steps and produce the numeric result - +a measure of the root mass in the image - without human intervention. +Implementing the steps within a Python function will enable us to call +this function for different images.

+

Here is a Python function that implements this root-mass-measuring +strategy. Since the function is intended to produce numeric output +without human interaction, it does not display any of the images. Almost +all of the commands should be familiar, and in fact, it may seem simpler +than the code we have worked on thus far, because we are not displaying +any of the images.

+
+

PYTHON +

+
def measure_root_mass(filename, sigma=1.0):
+
+    # read the original image, converting to grayscale on the fly
+    image = iio.imread(uri=filename, mode="L")
+
+    # blur before thresholding
+    blurred_image = ski.filters.gaussian(image, sigma=sigma)
+
+    # perform automatic thresholding to produce a binary image
+    t = ski.filters.threshold_otsu(blurred_image)
+    binary_mask = blurred_image > t
+
+    # determine root mass ratio
+    root_pixels = np.count_nonzero(binary_mask)
+    density = root_pixels / binary_mask.size
+
+    return density
+
+

The function begins with reading the original image from the file +filename. We use iio.imread() with the +optional argument mode="L" to automatically convert it to +grayscale. Next, the grayscale image is blurred with a Gaussian filter +with the value of sigma that is passed to the function. +Then we determine the threshold t with Otsu’s method and +create a binary mask just as we did in the previous section. Up to this +point, everything should be familiar.

+

The final part of the function determines the root mass ratio in the +image. Recall that in the binary_mask, every pixel has +either a value of zero (black/background) or one (white/foreground). We +want to count the number of white pixels, which can be accomplished with +a call to the NumPy function np.count_nonzero. Finally, the +density ratio is calculated by dividing the number of white pixels by +the total number of pixels binary_mask.size in the image. +The function returns then root density of the image.

+

We can call this function with any filename and provide a sigma value +for the blurring. If no sigma value is provided, the default value 1.0 +will be used. For example, for the file data/trial-016.jpg +and a sigma value of 1.5, we would call the function like this:

+
+

PYTHON +

+
measure_root_mass(filename="data/trial-016.jpg", sigma=1.5)
+
+
+

OUTPUT +

+
0.04907247340425532
+
+

Now we can use the function to process the series of four images +shown above. In a real-world scientific situation, there might be +dozens, hundreds, or even thousands of images to process. To save us the +tedium of calling the function for each image by hand, we can write a +loop that processes all files automatically. The following code block +assumes that the files are located in the same directory and the +filenames all start with the trial- prefix and end with +the .jpg suffix.

+
+

PYTHON +

+
all_files = sorted(glob.glob("data/trial-*.jpg"))
+for filename in all_files:
+    density = measure_root_mass(filename=filename, sigma=1.5)
+    # output in format suitable for .csv
+    print(filename, density, sep=",")
+
+
+

OUTPUT +

+
data/trial-016.jpg,0.04907247340425532
+data/trial-020.jpg,0.06381366356382978
+data/trial-216.jpg,0.14205152925531914
+data/trial-293.jpg,0.13665791223404256
+
+
+
+ +
+Callout +
+
+

Compare your results with the values above. Do they match exactly? +You may find that certain decimal values differ slightly, even when +using identical input parameters.

+

This variation often stems from the specific versions of your +installed packages (such as numpy or +scikit-image). As these libraries evolve, updates can +introduce subtle changes in numerical handling, underlying algorithms, +or rounding logic. This highlights why reproducible environments, as +well as reproducible code, are essential for consistent scientific +computing.

+
+
+
+
+
+ +
+Challenge +
+

Ignoring more of the images – brainstorming +(10 min)

+
+

Let us take a closer look at the binary masks produced by the +measure_root_mass function.

+
Binary masks of the four maize root images

You may have noticed in the section on automatic thresholding that +the thresholded image does include regions of the image aside of the +plant root: the numbered labels and the white circles in each image are +preserved during the thresholding, because their grayscale values are +above the threshold. Therefore, our calculated root mass ratios include +the white pixels of the label and white circle that are not part of the +plant root. Those extra pixels affect how accurate the root mass +calculation is!

+

How might we remove the labels and circles before calculating the +ratio, so that our results are more accurate? Think about some options +given what we have learned so far.

+
+
+
+
+
+ +
+
+

One approach we might take is to try to completely mask out a region +from each image, particularly, the area containing the white circle and +the numbered label. If we had coordinates for a rectangular area on the +image that contained the circle and the label, we could mask the area +out by using techniques we learned in the +Drawing and Bitwise Operations episode.

+

However, a closer inspection of the binary images raises some issues +with that approach. Since the roots are not always constrained to a +certain area in the image, and since the circles and labels are in +different locations each time, we would have difficulties coming up with +a single rectangle that would work for every image. We could +create a different masking rectangle for each image, but that is not a +practicable approach if we have hundreds or thousands of images to +process.

+

Another approach we could take is to apply two thresholding steps to +the image. Look at the graylevel histogram of the file +data/trial-016.jpg shown above again: Notice the peak near +1.0? Recall that a grayscale value of 1.0 corresponds to white pixels: +the peak corresponds to the white label and circle. So, we could use +simple binary thresholding to mask the white circle and label from the +image, and then we could use Otsu’s method to select the pixels in the +plant portion of the image.

+

Note that most of this extra work in processing the image could have +been avoided during the experimental design stage, with some careful +consideration of how the resulting images would be used. For example, +all of the following measures could have made the images easier to +process, by helping us predict and/or detect where the label is in the +image and subsequently mask it from further processing:

+
    +
  • Using labels with a consistent size and shape
  • +
  • Placing all the labels in the same position, relative to the +sample
  • +
  • Using a non-white label, with non-black writing
  • +
+
+
+
+
+
+
+ +
+Challenge +
+

Ignoring more of the images – implementation +(30 min - optional, not included in timing)

+
+

Implement an enhanced version of the function +measure_root_mass that applies simple binary thresholding +to remove the white circle and label from the image before applying +Otsu’s method.

+
+
+
+
+
+ +
+
+

We can apply a simple binary thresholding with a threshold +t=0.95 to remove the label and circle from the image. We +can then use the binary mask to calculate the Otsu threshold without the +pixels from the label and circle.

+
+

PYTHON +

+
def enhanced_root_mass(filename, sigma):
+
+    # read the original image, converting to grayscale on the fly
+    image = iio.imread(uri=filename, mode="L")
+
+    # blur before thresholding
+    blurred_image = ski.filters.gaussian(image, sigma=sigma)
+
+    # perform binary thresholding to mask the white label and circle
+    binary_mask = blurred_image < 0.95
+
+    # perform automatic thresholding using only the pixels with value True in the binary mask
+    t = ski.filters.threshold_otsu(blurred_image[binary_mask])
+
+    # update binary mask to identify pixels which are both less than 0.95 and greater than t
+    binary_mask = (blurred_image < 0.95) & (blurred_image > t)
+
+    # determine root mass ratio
+    root_pixels = np.count_nonzero(binary_mask)
+    density = root_pixels / binary_mask.size
+
+    return density
+
+
+all_files = sorted(glob.glob("data/trial-*.jpg"))
+for filename in all_files:
+    density = enhanced_root_mass(filename=filename, sigma=1.5)
+    # output in format suitable for .csv
+    print(filename, density, sep=",")
+
+

The output of the improved program does illustrate that the white +circles and labels were skewing our root mass ratios:

+
+

OUTPUT +

+
data/trial-016.jpg,0.046261136968085106
+data/trial-020.jpg,0.05887167553191489
+data/trial-216.jpg,0.13712067819148935
+data/trial-293.jpg,0.1319044215425532
+
+
+
+ +
+
+

The & operator above means that we have defined a +logical AND statement. This combines the two tests of pixel intensities +in the blurred image such that both must be true for a pixel’s position +to be set to True in the resulting mask.

+ +++++ + + + + + + + + + + + + + + + + + + + + + + +
Result of t < blurred_image +Result of blurred_image < 0.95 +Resulting value in binary_mask +
FalseTrueFalse
TrueFalseFalse
TrueTrueTrue
+

Knowing how to construct this kind of logical operation can be very +helpful in image processing. The University of Minnesota Library’s guide to Boolean +operators is a good place to start if you want to learn more.

+
+
+
+
+

Here are the binary images produced by the additional thresholding. +Note that we have not completely removed the offending white pixels. +Outlines still remain. However, we have reduced the number of extraneous +pixels, which should make the output more accurate.

+
Improved binary masks of the four maize root images
+
+
+
+
+
+
+ +
+Challenge +
+

Thresholding a bacteria colony image (15 +min)

+
+

In the images directory data/, you will find an image +named colonies-01.tif.

+
Image of bacteria colonies in a petri dish

This is one of the images you will be working with in the +morphometric challenge at the end of the workshop.

+
    +
  1. Plot and inspect the grayscale histogram of the image to determine a +good threshold value for the image.
  2. +
  3. Create a binary mask that leaves the pixels in the bacteria colonies +“on” while turning the rest of the pixels in the image “off”.
  4. +
+
+
+
+
+
+ +
+
+

Here is the code to create the grayscale histogram:

+
+

PYTHON +

+
bacteria = iio.imread(uri="data/colonies-01.tif")
+gray_image = ski.color.rgb2gray(bacteria)
+blurred_image = ski.filters.gaussian(gray_image, sigma=1.0)
+histogram, bin_edges = np.histogram(blurred_image, bins=256, range=(0.0, 1.0))
+fig, ax = plt.subplots()
+ax.plot(bin_edges[0:-1], histogram)
+ax.set_title("Graylevel histogram")
+ax.set_xlabel("gray value")
+ax.set_ylabel("pixel count")
+ax.set_xlim(0, 1.0)
+
+
Grayscale histogram of the bacteria colonies image

The peak near one corresponds to the white image background, and the +broader peak around 0.5 corresponds to the yellow/brown culture medium +in the dish. The small peak near zero is what we are after: the dark +bacteria colonies. A reasonable choice thus might be to leave pixels +below t=0.2 on.

+

Here is the code to create and show the binarized image using the +< operator with a threshold t=0.2:

+
+

PYTHON +

+
t = 0.2
+binary_mask = blurred_image < t
+
+fig, ax = plt.subplots()
+ax.imshow(binary_mask, cmap="gray")
+
+
Binary mask of the bacteria colonies image

When you experiment with the threshold a bit, you can see that in +particular the size of the bacteria colony near the edge of the dish in +the top right is affected by the choice of the threshold.

+
+
+
+
+
+
+ +
+Key Points +
+
+
    +
  • Thresholding produces a binary image, where all pixels with +intensities above (or below) a threshold value are turned on, while all +other pixels are turned off.
  • +
  • The binary images produced by thresholding are held in +two-dimensional NumPy arrays, since they have only one colour value +channel. They are boolean, hence they contain the values 0 (off) and 1 +(on).
  • +
  • Thresholding can be used to create masks that select only the +interesting parts of an image, or as the first step before edge +detection or finding contours.
  • +
+
+
+
+

Content from Connected Component Analysis

+
+

Last updated on 2026-03-20 | + + Edit this page

+
+ +
+
+

Overview

+
+
+
+
+

Questions

+
    +
  • How to extract separate objects from an image and describe these +objects quantitatively.
  • +
+
+
+
+
+
+
+

Objectives

+
    +
  • Understand the term object in the context of images.
  • +
  • Learn about pixel connectivity.
  • +
  • Learn how Connected Component Analysis (CCA) works.
  • +
  • Use CCA to produce an image that highlights every object in a +different colour.
  • +
  • Characterise each object with numbers that describe its +appearance.
  • +
+
+
+
+
+
+

Objects +

+
+

In the Thresholding +episode we have covered dividing an image into foreground and +background pixels. In the shapes example image, we considered the +coloured shapes as foreground objects on a white +background.

+
Original shapes image

In thresholding we went from the original image to this version:

+
Mask created by thresholding

Here, we created a mask that only highlights the parts of the image +that we find interesting, the objects. All objects have pixel +value of True while the background pixels are +False.

+

By looking at the mask image, one can count the objects that are +present in the image (7). But how did we actually do that, how did we +decide which lump of pixels constitutes a single object?

+ +

Pixel Neighborhoods +

+
+

In order to decide which pixels belong to the same object, one can +exploit their neighborhood: pixels that are directly next to each other +and belong to the foreground class can be considered to belong to the +same object.

+

Let’s discuss the concept of pixel neighborhoods in more detail. +Consider the following mask “image” with 8 rows, and 8 columns. For the +purpose of illustration, the digit 0 is used to represent +background pixels, and the letter X is used to represent +object pixels foreground).

+
+

OUTPUT +

+
0 0 0 0 0 0 0 0
+0 X X 0 0 0 0 0
+0 X X 0 0 0 0 0
+0 0 0 X X X 0 0
+0 0 0 X X X X 0
+0 0 0 0 0 0 0 0
+
+

The pixels are organised in a rectangular grid. In order to +understand pixel neighborhoods we will introduce the concept of “jumps” +between pixels. The jumps follow two rules: First rule is that one jump +is only allowed along the column, or the row. Diagonal jumps are not +allowed. So, from a centre pixel, denoted with o, only the +pixels indicated with a 1 are reachable:

+
+

OUTPUT +

+
- 1 -
+1 o 1
+- 1 -
+
+

The pixels on the diagonal (from o) are not reachable +with a single jump, which is denoted by the -. The pixels +reachable with a single jump form the 1-jump +neighborhood.

+

The second rule states that in a sequence of jumps, one may only jump +in row and column direction once -> they have to be +orthogonal. An example of a sequence of orthogonal jumps is +shown below. Starting from o the first jump goes along the +row to the right. The second jump then goes along the column direction +up. After this, the sequence cannot be continued as a jump has already +been made in both row and column direction.

+
+

OUTPUT +

+
- - 2
+- o 1
+- - -
+
+

All pixels reachable with one, or two jumps form the +2-jump neighborhood. The grid below illustrates the +pixels reachable from the centre pixel o with a single +jump, highlighted with a 1, and the pixels reachable with 2 +jumps with a 2.

+
+

OUTPUT +

+
2 1 2
+1 o 1
+2 1 2
+
+

We want to revisit our example image mask from above and apply the +two different neighborhood rules. With a single jump connectivity for +each pixel, we get two resulting objects, highlighted in the image with +A’s and B’s.

+
+

OUTPUT +

+
0 0 0 0 0 0 0 0
+0 A A 0 0 0 0 0
+0 A A 0 0 0 0 0
+0 0 0 B B B 0 0
+0 0 0 B B B B 0
+0 0 0 0 0 0 0 0
+
+

In the 1-jump version, only pixels that have direct neighbors along +rows or columns are considered connected. Diagonal connections are not +included in the 1-jump neighborhood. With two jumps, however, we only +get a single object A because pixels are also considered +connected along the diagonals.

+
+

OUTPUT +

+
0 0 0 0 0 0 0 0
+0 A A 0 0 0 0 0
+0 A A 0 0 0 0 0
+0 0 0 A A A 0 0
+0 0 0 A A A A 0
+0 0 0 0 0 0 0 0
+
+
+
+ +
+Challenge +
+

Object counting (optional, not included in +timing)

+
+

How many objects with 1 orthogonal jump, how many with 2 orthogonal +jumps?

+
+

OUTPUT +

+
0 0 0 0 0 0 0 0
+0 X 0 0 0 X X 0
+0 0 X 0 0 0 0 0
+0 X 0 X X X 0 0
+0 X 0 X X 0 0 0
+0 0 0 0 0 0 0 0
+
+

1 jump

+
    +
  1. 1
  2. +
  3. 5
  4. +
  5. 2
  6. +
+
+
+
+
+
+ +
+
+
    +
  1. 5
  2. +
+
+
+
+
+
+
+ +
+Challenge +
+

Object counting (optional, not included in +timing) (continued) +

+
+

2 jumps

+
    +
  1. 2
  2. +
  3. 3
  4. +
  5. 5
  6. +
+
+
+
+
+
+ +
+
+
    +
  1. 2
  2. +
+
+
+
+
+
+
+ +
+Callout +
+

Jumps and neighborhoods

+
+

We have just introduced how you can reach different neighboring +pixels by performing one or more orthogonal jumps. We have used the +terms 1-jump and 2-jump neighborhood. There is also a different way of +referring to these neighborhoods: the 4- and 8-neighborhood. With a +single jump you can reach four pixels from a given starting pixel. +Hence, the 1-jump neighborhood corresponds to the 4-neighborhood. When +two orthogonal jumps are allowed, eight pixels can be reached, so the +2-jump neighborhood corresponds to the 8-neighborhood.

+
+
+
+

Connected Component Analysis +

+
+

In order to find the objects in an image, we want to employ an +operation that is called Connected Component Analysis (CCA). This +operation takes a binary image as an input. Usually, the +False value in this image is associated with background +pixels, and the True value indicates foreground, or object +pixels. Such an image can be produced, e.g., with thresholding. Given a +thresholded image, the connected component analysis produces a new +labeled image with integer pixel values. Pixels with the same +value, belong to the same object. scikit-image provides connected +component analysis in the function ski.measure.label(). Let +us add this function to the already familiar steps of thresholding an +image.

+

First, import the packages needed for this episode:

+
+

PYTHON +

+
import imageio.v3 as iio
+import ipympl
+import matplotlib.pyplot as plt
+import numpy as np
+import skimage as ski
+
+%matplotlib widget
+
+

In this episode, we will use the ski.measure.label +function to perform the CCA.

+

Next, we define a reusable Python function +connected_components:

+
+

PYTHON +

+
def connected_components(filename, sigma=1.0, t=0.5, connectivity=2):
+    # load the image
+    image = iio.imread(filename)
+    # convert the image to grayscale
+    gray_image = ski.color.rgb2gray(image)
+    # denoise the image with a Gaussian filter
+    blurred_image = ski.filters.gaussian(gray_image, sigma=sigma)
+    # mask the image according to threshold
+    binary_mask = blurred_image < t
+    # perform connected component analysis
+    labeled_image, count = ski.measure.label(binary_mask,
+                                                 connectivity=connectivity, return_num=True)
+    return labeled_image, count
+
+

The first four lines of code are familiar from the Thresholding episode.

+ +

Then we call the ski.measure.label function. This +function has one positional argument where we pass the +binary_mask, i.e., the binary image to work on. With the +optional argument connectivity, we specify the neighborhood +in units of orthogonal jumps. For example, by setting +connectivity=2 we will consider the 2-jump neighborhood +introduced above. The function returns a labeled_image +where each pixel has a unique value corresponding to the object it +belongs to. In addition, we pass the optional parameter +return_num=True to return the maximum label index as +count.

+
+
+ +
+Callout +
+

Optional parameters and return values

+
+

The optional parameter return_num changes the data type +that is returned by the function ski.measure.label. The +number of labels is only returned if return_num is +True. Otherwise, the function only returns the labeled image. +This means that we have to pay attention when assigning the return value +to a variable. If we omit the optional parameter return_num +or pass return_num=False, we can call the function as

+
+

PYTHON +

+
labeled_image = ski.measure.label(binary_mask)
+
+

If we pass return_num=True, the function returns a tuple +and we can assign it as

+
+

PYTHON +

+
labeled_image, count = ski.measure.label(binary_mask, return_num=True)
+
+

If we used the same assignment as in the first case, the variable +labeled_image would become a tuple, in which +labeled_image[0] is the image and +labeled_image[1] is the number of labels. This could cause +confusion if we assume that labeled_image only contains the +image and pass it to other functions. If you get an +AttributeError: 'tuple' object has no attribute 'shape' or +similar, check if you have assigned the return values consistently with +the optional parameters.

+
+
+
+

We can call the above function connected_components and +display the labeled image like so:

+
+

PYTHON +

+
labeled_image, count = connected_components(filename="data/shapes-01.jpg", sigma=2.0, t=0.9, connectivity=2)
+
+fig, ax = plt.subplots()
+ax.imshow(labeled_image)
+ax.set_axis_off();
+
+
+
+ +
+
+

If you are using an older version of Matplotlib you might get a +warning +UserWarning: Low image data range; displaying image with stretched contrast. +or just see a visually empty image.

+

What went wrong? When you hover over the image, the pixel values are +shown as numbers in the lower corner of the viewer. You can see that +some pixels have values different from 0, so they are not +actually all the same value. Let’s find out more by examining +labeled_image. Properties that might be interesting in this +context are dtype, the minimum and maximum value. We can +print them with the following lines:

+
+

PYTHON +

+
print("dtype:", labeled_image.dtype)
+print("min:", np.min(labeled_image))
+print("max:", np.max(labeled_image))
+
+

Examining the output can give us a clue why the image appears +empty.

+
+

OUTPUT +

+
dtype: int32
+min: 0
+max: 11
+
+

The dtype of labeled_image is +int32. This means that values in this image range from +-2 ** 31 to 2 ** 31 - 1. Those are really big +numbers. From this available space we only use the range from +0 to 11. When showing this image in the +viewer, it may squeeze the complete range into 256 gray values. +Therefore, the range of our numbers does not produce any visible +variation. One way to rectify this is to explicitly specify the data +range we want the colormap to cover:

+
+

PYTHON +

+
fig, ax = plt.subplots()
+ax.imshow(labeled_image, vmin=np.min(labeled_image), vmax=np.max(labeled_image))
+
+

Note this is the default behaviour for newer versions of +matplotlib.pyplot.imshow. Alternatively we could convert +the image to RGB and then display it.

+
+
+
+
+
+
+ +
+Callout +
+

Suppressing outputs in Jupyter Notebooks

+
+

We just used ax.set_axis_off(); to hide the axis from +the image for a visually cleaner figure. The semicolon is added to +supress the output(s) of the statement, in this case +the axis limits. This is specific to Jupyter Notebooks.

+
+
+
+

We can use the function ski.color.label2rgb() to convert +the 32-bit grayscale labeled image to standard RGB colour (recall that +we already used the ski.color.rgb2gray() function to +convert to grayscale). With ski.color.label2rgb(), all +objects are coloured according to a list of colours that can be +customised. We can use the following commands to convert and show the +image:

+
+

PYTHON +

+
# convert the label image to color image
+colored_label_image = ski.color.label2rgb(labeled_image, bg_label=0)
+
+fig, ax = plt.subplots()
+ax.imshow(colored_label_image)
+ax.set_axis_off();
+
+
Labeled objects
+
+ +
+Challenge +
+

How many objects are in that image (15 +min)

+
+

Now, it is your turn to practice. Using the function +connected_components, find two ways of printing out the +number of objects found in the image.

+

What number of objects would you expect to get?

+

How does changing the sigma and threshold +values influence the result?

+
+
+
+
+
+ +
+
+

As you might have guessed, the return value count +already contains the number of objects found in the image. So it can +simply be printed with

+
+

PYTHON +

+
print("Found", count, "objects in the image.")
+
+

But there is also a way to obtain the number of found objects from +the labeled image itself. Recall that all pixels that belong to a single +object are assigned the same integer value. The connected component +algorithm produces consecutive numbers. The background gets the value +0, the first object gets the value 1, the +second object the value 2, and so on. This means that by +finding the object with the maximum value, we also know how many objects +there are in the image. We can thus use the np.max function +from NumPy to find the maximum value that equals the number of found +objects:

+
+

PYTHON +

+
num_objects = np.max(labeled_image)
+print("Found", num_objects, "objects in the image.")
+
+

Invoking the function with sigma=2.0, and +threshold=0.9, both methods will print

+
+

OUTPUT +

+
Found 11 objects in the image.
+
+

Lowering the threshold will result in fewer objects. The higher the +threshold is set, the more objects are found. More and more background +noise gets picked up as objects. Larger sigmas produce binary masks with +less noise and hence a smaller number of objects. Setting sigma too high +bears the danger of merging objects.

+
+
+
+
+

You might wonder why the connected component analysis with +sigma=2.0, and threshold=0.9 finds 11 objects, +whereas we would expect only 7 objects. Where are the four additional +objects? With a bit of detective work, we can spot some small objects in +the image, for example, near the left border.

+
shapes-01.jpg mask detail

For us it is clear that these small spots are artifacts and not +objects we are interested in. But how can we tell the computer? One way +to calibrate the algorithm is to adjust the parameters for blurring +(sigma) and thresholding (t), but you may have +noticed during the above exercise that it is quite hard to find a +combination that produces the right output number. In some cases, +background noise gets picked up as an object. And with other parameters, +some of the foreground objects get broken up or disappear completely. +Therefore, we need other criteria to describe desired properties of the +objects that are found.

+

Morphometrics - Describe object features with numbers +

+
+

Morphometrics is concerned with the quantitative analysis of objects +and considers properties such as size and shape. For the example of the +images with the shapes, our intuition tells us that the objects should +be of a certain size or area. So we could use a minimum area as a +criterion for when an object should be detected. To apply such a +criterion, we need a way to calculate the area of objects found by +connected components. Recall how we determined the root mass in the Thresholding episode by +counting the pixels in the binary mask. But here we want to calculate +the area of several objects in the labeled image. The scikit-image +library provides the function ski.measure.regionprops to +measure the properties of labeled regions. It returns a list of +RegionProperties that describe each connected region in the +images. The properties can be accessed using the attributes of the +RegionProperties data type. Here we will use the properties +"area" and "label". You can explore the +scikit-image documentation to learn about other properties +available.

+

We can get a list of areas of the labeled objects as follows:

+
+

PYTHON +

+
# compute object features and extract object areas
+object_features = ski.measure.regionprops(labeled_image)
+object_areas = [objf["area"] for objf in object_features]
+object_areas
+
+

This will produce the output

+
+

OUTPUT +

+
[318542, 1, 523204, 496613, 517331, 143, 256215, 1, 68, 338784, 265755]
+
+
+
+ +
+Challenge +
+

Plot a histogram of the object area +distribution (10 min)

+
+

Similar to how we determined a “good” threshold in the Thresholding episode, it is +often helpful to inspect the histogram of an object property. For +example, we want to look at the distribution of the object areas.

+
    +
  1. Create and examine a histogram of the object areas +obtained with ski.measure.regionprops.
  2. +
  3. What does the histogram tell you about the objects?
  4. +
+
+
+
+
+
+ +
+
+

The histogram can be plotted with

+
+

PYTHON +

+
fig, ax = plt.subplots()
+ax.hist(object_areas)
+ax.set_xlabel("Area (pixels)")
+ax.set_ylabel("Number of objects");
+
+
Histogram of object areas

The histogram shows the number of objects (vertical axis) whose area +is within a certain range (horizontal axis). The height of the bars in +the histogram indicates the prevalence of objects with a certain area. +The whole histogram tells us about the distribution of object sizes in +the image. It is often possible to identify gaps between groups of bars +(or peaks if we draw the histogram as a continuous curve) that tell us +about certain groups in the image.

+

In this example, we can see that there are four small objects that +contain less than 50000 pixels. Then there is a group of four (1+1+2) +objects in the range between 200000 and 400000, and three objects with a +size around 500000. For our object count, we might want to disregard the +small objects as artifacts, i.e, we want to ignore the leftmost bar of +the histogram. We could use a threshold of 50000 as the minimum area to +count. In fact, the object_areas list already tells us that +there are fewer than 200 pixels in these objects. Therefore, it is +reasonable to require a minimum area of at least 200 pixels for a +detected object. In practice, finding the “right” threshold can be +tricky and usually involves an educated guess based on domain +knowledge.

+
+
+
+
+
+
+ +
+Challenge +
+

Filter objects by area (10 min)

+
+

Now we would like to use a minimum area criterion to obtain a more +accurate count of the objects in the image.

+
    +
  1. Find a way to calculate the number of objects by only counting +objects above a certain area.
  2. +
+
+
+
+
+
+ +
+
+

One way to count only objects above a certain area is to first create +a list of those objects, and then take the length of that list as the +object count. This can be done as follows:

+
+

PYTHON +

+
min_area = 200
+large_objects = []
+for objf in object_features:
+    if objf["area"] > min_area:
+        large_objects.append(objf["label"])
+print("Found", len(large_objects), "objects!")
+
+

Another option is to use NumPy arrays to create the list of large +objects. We first create an array object_areas containing +the object areas, and an array object_labels containing the +object labels. The labels of the objects are also returned by +ski.measure.regionprops. We have already seen that we can +create boolean arrays using comparison operators. Here we can use +object_areas > min_area to produce an array that has the +same dimension as object_labels. It can then be used to +select the labels of objects whose area is greater than +min_area by indexing:

+
+

PYTHON +

+
object_areas = np.array([objf["area"] for objf in object_features])
+object_labels = np.array([objf["label"] for objf in object_features])
+large_objects = object_labels[object_areas > min_area]
+print("Found", len(large_objects), "objects!")
+
+

The advantage of using NumPy arrays is that for loops +and if statements in Python can be slow, and in practice +the first approach may not be feasible if the image contains a large +number of objects. In that case, NumPy array functions turn out to be +very useful because they are much faster.

+

In this example, we can also use the np.count_nonzero +function that we have seen earlier together with the > +operator to count the objects whose area is above +min_area.

+
+

PYTHON +

+
n = np.count_nonzero(object_areas > min_area)
+print("Found", n, "objects!")
+
+

For all three alternatives, the output is the same and gives the +expected count of 7 objects.

+
+
+
+
+
+
+ +
+Callout +
+

Using functions from NumPy and other Python +packages

+
+

Functions from Python packages such as NumPy are often more efficient +and require less code to write. It is a good idea to browse the +reference pages of numpy and skimage to look +for an availabe function that can solve a given task.

+
+
+
+
+
+ +
+Challenge +
+

Remove small objects (20 min)

+
+

We might also want to exclude (mask) the small objects when plotting +the labeled image.

+
    +
  1. Enhance the connected_components function such that it +automatically removes objects that are below a certain area that is +passed to the function as an optional parameter.
  2. +
+
+
+
+
+
+ +
+
+

To remove the small objects from the labeled image, we change the +value of all pixels that belong to the small objects to the background +label 0. One way to do this is to loop over all objects and set the +pixels that match the label of the object to 0.

+
+

PYTHON +

+
for object_id, objf in enumerate(object_features, start=1):
+    if objf["area"] < min_area:
+        labeled_image[labeled_image == objf["label"]] = 0
+
+

Here NumPy functions can also be used to eliminate for +loops and if statements. Like above, we can create an array +of the small object labels with the comparison +object_areas < min_area. We can use another NumPy +function, np.isin, to set the pixels of all small objects +to 0. np.isin takes two arrays and returns a boolean array +with values True if the entry of the first array is found +in the second array, and False otherwise. This array can +then be used to index the labeled_image and set the entries +that belong to small objects to 0.

+
+

PYTHON +

+
object_areas = np.array([objf["area"] for objf in object_features])
+object_labels = np.array([objf["label"] for objf in object_features])
+small_objects = object_labels[object_areas < min_area]
+labeled_image[np.isin(labeled_image, small_objects)] = 0
+
+

An even more elegant way to remove small objects from the image is to +leverage the ski.morphology module. It provides a function +ski.morphology.remove_small_objects that does exactly what +we are looking for. It can be applied to a binary image and returns a +mask in which all objects smaller than min_area are +excluded, i.e, their pixel values are set to False. We can +then apply ski.measure.label to the masked image:

+
+

PYTHON +

+
object_mask = ski.morphology.remove_small_objects(binary_mask, min_size=min_area)
+labeled_image, n = ski.measure.label(object_mask,
+                                         connectivity=connectivity, return_num=True)
+
+

Using the scikit-image features, we can implement the +enhanced_connected_component as follows:

+
+

PYTHON +

+
def enhanced_connected_components(filename, sigma=1.0, t=0.5, connectivity=2, min_area=0):
+    image = iio.imread(filename)
+    gray_image = ski.color.rgb2gray(image)
+    blurred_image = ski.filters.gaussian(gray_image, sigma=sigma)
+    binary_mask = blurred_image < t
+    object_mask = ski.morphology.remove_small_objects(binary_mask, min_size=min_area)
+    labeled_image, count = ski.measure.label(object_mask,
+                                                 connectivity=connectivity, return_num=True)
+    return labeled_image, count
+
+

We can now call the function with a chosen min_area and +display the resulting labeled image:

+
+

PYTHON +

+
labeled_image, count = enhanced_connected_components(filename="data/shapes-01.jpg", sigma=2.0, t=0.9,
+                                                     connectivity=2, min_area=min_area)
+colored_label_image = ski.color.label2rgb(labeled_image, bg_label=0)
+
+fig, ax = plt.subplots()
+ax.imshow(colored_label_image)
+ax.set_axis_off();
+
+print("Found", count, "objects in the image.")
+
+
Objects filtered by area
+

OUTPUT +

+
Found 7 objects in the image.
+
+

Note that the small objects are “gone” and we obtain the correct +number of 7 objects in the image.

+
+
+
+
+
+
+ +
+Challenge +
+

Colour objects by area (optional, not included +in timing)

+
+

Finally, we would like to display the image with the objects coloured +according to the magnitude of their area. In practice, this can be used +with other properties to give visual cues of the object properties.

+
+
+
+
+
+ +
+
+

We already know how to get the areas of the objects from the +regionprops. We just need to insert a zero area value for +the background (to colour it like a zero size object). The background is +also labeled 0 in the labeled_image, so we +insert the zero area value in front of the first element of +object_areas with np.insert. Then we can +create a colored_area_image where we assign each pixel +value the area by indexing the object_areas with the label +values in labeled_image.

+
+

PYTHON +

+
object_areas = np.array([objf["area"] for objf in ski.measure.regionprops(labeled_image)])
+# prepend zero to object_areas array for background pixels
+object_areas = np.insert(0, obj=1, values=object_areas)
+# create image where the pixels in each object are equal to that object's area
+colored_area_image = object_areas[labeled_image]
+
+fig, ax = plt.subplots()
+im = ax.imshow(colored_area_image)
+cbar = fig.colorbar(im, ax=ax, shrink=0.85)
+cbar.ax.set_title("Area")
+ax.set_axis_off();
+
+
Objects colored by area
+
+ +
+Callout +
+
+

You may have noticed that in the solution, we have used the +labeled_image to index the array object_areas. +This is an example of advanced +indexing in NumPy The result is an array of the same shape as the +labeled_image whose pixel values are selected from +object_areas according to the object label. Hence the +objects will be colored by area when the result is displayed. Note that +advanced indexing with an integer array works slightly different than +the indexing with a Boolean array that we have used for masking. While +Boolean array indexing returns only the entries corresponding to the +True values of the index, integer array indexing returns an +array with the same shape as the index. You can read more about advanced +indexing in the NumPy +documentation.

+
+
+
+
+
+
+
+
+
+ +
+Key Points +
+
+
    +
  • We can use ski.measure.label to find and label +connected objects in an image.
  • +
  • We can use ski.measure.regionprops to measure +properties of labeled objects.
  • +
  • We can use ski.morphology.remove_small_objects to mask +small objects and remove artifacts from an image.
  • +
  • We can display the labeled image to view the objects coloured by +label.
  • +
+
+
+
+

Content from Capstone Challenge

+
+

Last updated on 2026-03-23 | + + Edit this page

+
+ +
+
+

Overview

+
+
+
+
+

Questions

+
    +
  • How can we automatically count bacterial colonies with image +analysis?
  • +
+
+
+
+
+
+
+

Objectives

+
    +
  • Bring together everything you’ve learnt so far to count bacterial +colonies in 3 images.
  • +
+
+
+
+
+
+

In this episode, we will provide a final challenge for you to +attempt, based on all the skills you have acquired so far. This +challenge will be related to the shape of objects in images +(morphometrics).

+

Morphometrics: Bacteria Colony Counting +

+
+

As mentioned in the workshop +introduction, your morphometric challenge is to determine how many +bacteria colonies are in each of these images:

+
Colony image 1
Colony image 2
Colony image 3

The image files can be found at data/colonies-01.tif, +data/colonies-02.tif, and +data/colonies-03.tif.

+
+
+ +
+Challenge +
+

Morphometrics for bacterial colonies

+
+

Write a Python program that uses scikit-image to count the number of +bacteria colonies in each image, and for each, produce a new image that +highlights the colonies. The image should look similar to this one:

+
Sample morphometric output

Additionally, print out the number of colonies for each image.

+

Use what you have learnt about histograms, thresholding and connected component analysis. +Try to put your code into a re-usable function, so that it can be +applied conveniently to any image file.

+
+
+
+
+
+ +
+
+

First, let’s work through the process for one image:

+
+

PYTHON +

+
import imageio.v3 as iio
+import ipympl
+import matplotlib.pyplot as plt
+import numpy as np
+import skimage as ski
+
+%matplotlib widget
+
+bacteria_image = iio.imread(uri="data/colonies-01.tif")
+
+# display the image
+fig, ax = plt.subplots()
+ax.imshow(bacteria_image)
+
+
Colony image 1

Next, we need to threshold the image to create a mask that covers +only the dark bacterial colonies. This is easier using a grayscale +image, so we convert it here:

+
+

PYTHON +

+
gray_bacteria = ski.color.rgb2gray(bacteria_image)
+
+# display the gray image
+fig, ax = plt.subplots()
+ax.imshow(gray_bacteria, cmap="gray")
+
+
Gray Colonies

Next, we blur the image and create a histogram:

+
+

PYTHON +

+
blurred_image = ski.filters.gaussian(gray_bacteria, sigma=1.0)
+histogram, bin_edges = np.histogram(blurred_image, bins=256, range=(0.0, 1.0))
+fig, ax = plt.subplots()
+ax.plot(bin_edges[0:-1], histogram)
+ax.set_title("Graylevel histogram")
+ax.set_xlabel("gray value")
+ax.set_ylabel("pixel count")
+ax.set_xlim(0, 1.0)
+
+
Histogram image

In this histogram, we see three peaks - the left one (i.e. the +darkest pixels) is our colonies, the central peak is the yellow/brown +culture medium in the dish, and the right one (i.e. the brightest +pixels) is the white image background. Therefore, we choose a threshold +that selects the small left peak:

+
+

PYTHON +

+
mask = blurred_image < 0.2
+fig, ax = plt.subplots()
+ax.imshow(mask, cmap="gray")
+
+
Colony mask image

This mask shows us where the colonies are in the image - but how can +we count how many there are? This requires connected component +analysis:

+
+

PYTHON +

+
labeled_image, count = ski.measure.label(mask, return_num=True)
+print(count)
+
+

Finally, we create the summary image of the coloured colonies on top +of the grayscale image:

+
+

PYTHON +

+
# color each of the colonies a different color
+colored_label_image = ski.color.label2rgb(labeled_image, bg_label=0)
+# give our grayscale image rgb channels, so we can add the colored colonies
+summary_image = ski.color.gray2rgb(gray_bacteria)
+summary_image[mask] = colored_label_image[mask]
+
+# plot overlay
+fig, ax = plt.subplots()
+ax.imshow(summary_image)
+
+
Sample morphometric output

Now that we’ve completed the task for one image, we need to repeat +this for the remaining two images. This is a good point to collect the +lines above into a re-usable function:

+
+

PYTHON +

+
def count_colonies(image_filename):
+    bacteria_image = iio.imread(image_filename)
+    gray_bacteria = ski.color.rgb2gray(bacteria_image)
+    blurred_image = ski.filters.gaussian(gray_bacteria, sigma=1.0)
+    mask = blurred_image < 0.2
+    labeled_image, count = ski.measure.label(mask, return_num=True)
+    print(f"There are {count} colonies in {image_filename}")
+
+    colored_label_image = ski.color.label2rgb(labeled_image, bg_label=0)
+    summary_image = ski.color.gray2rgb(gray_bacteria)
+    summary_image[mask] = colored_label_image[mask]
+    fig, ax = plt.subplots()
+    ax.imshow(summary_image)
+
+

Now we can do this analysis on all the images via a for loop:

+
+

PYTHON +

+
for image_filename in ["data/colonies-01.tif", "data/colonies-02.tif", "data/colonies-03.tif"]:
+    count_colonies(image_filename=image_filename)
+
+

Colony 1 outputColony 2 outputColony 3 output

+

You’ll notice that for the images with more colonies, the results +aren’t perfect. For example, some small colonies are missing, and there +are likely some small black spots being labelled incorrectly as +colonies. You could expand this solution to, for example, use an +automatically determined threshold for each image, which may fit each +better. Also, you could filter out colonies below a certain size (as we +did in the Connected +Component Analysis episode). You’ll also see that some touching +colonies are merged into one big colony. This could be fixed with more +complicated segmentation methods (outside of the scope of this lesson) +like watershed.

+
+
+
+
+
+
+ +
+Challenge +
+

Colony counting with minimum size and +automated threshold (optional, not included in timing)

+
+

Modify your function from the previous exercise for colony counting +to (i) exclude objects smaller than a specified size and (ii) use an +automated thresholding approach, e.g. Otsu, to mask the colonies.

+
+
+
+
+
+ +
+
+

Here is a modified function with the requested features. Note when +calculating the Otsu threshold we don’t include the very bright pixels +outside the dish.

+
+

PYTHON +

+
def count_colonies_enhanced(image_filename, sigma=1.0, min_colony_size=10, connectivity=2):
+    
+    bacteria_image = iio.imread(image_filename)
+    gray_bacteria = ski.color.rgb2gray(bacteria_image)
+    blurred_image = ski.filters.gaussian(gray_bacteria, sigma=sigma)
+    
+    # create mask excluding the very bright pixels outside the dish
+    # we dont want to include these when calculating the automated threshold
+    mask = blurred_image < 0.90
+    # calculate an automated threshold value within the dish using the Otsu method
+    t = ski.filters.threshold_otsu(blurred_image[mask])
+    # update mask to select pixels both within the dish and less than t
+    mask = np.logical_and(mask, blurred_image < t)
+    # remove objects smaller than specified area
+    mask = ski.morphology.remove_small_objects(mask, min_size=min_colony_size)
+    
+    labeled_image, count = ski.measure.label(mask, return_num=True)
+    print(f"There are {count} colonies in {image_filename}")
+    colored_label_image = ski.color.label2rgb(labeled_image, bg_label=0)
+    summary_image = ski.color.gray2rgb(gray_bacteria)
+    summary_image[mask] = colored_label_image[mask]
+    fig, ax = plt.subplots()
+    ax.imshow(summary_image)
+
+
+
+
+
+
+
+ +
+Key Points +
+
+
    +
  • Using thresholding, connected component analysis and other tools we +can automatically segment images of bacterial colonies.
  • +
  • These methods are useful for many scientific problems, especially +those involving morphometrics.
  • +
+
+
+
+
+
+ +
+Discussion +
+

Where to go from here?

+
+

Take a look at our curated list of +resources for further publicly available courses, resources and +scientific literature around image processing and more.

+
+
+
+
+
+
+
+ + +
+ + +
+ + + + + diff --git a/android-chrome-192x192.png b/android-chrome-192x192.png new file mode 100644 index 000000000..ed3c210ab Binary files /dev/null and b/android-chrome-192x192.png differ diff --git a/android-chrome-512x512.png b/android-chrome-512x512.png new file mode 100644 index 000000000..c88d96c1c Binary files /dev/null and b/android-chrome-512x512.png differ diff --git a/apple-touch-icon.png b/apple-touch-icon.png new file mode 100644 index 000000000..8044feefd Binary files /dev/null and b/apple-touch-icon.png differ diff --git a/assets/fonts/Mulish-Black.eot b/assets/fonts/Mulish-Black.eot new file mode 100644 index 000000000..a09b6937f Binary files /dev/null and b/assets/fonts/Mulish-Black.eot differ diff --git a/assets/fonts/Mulish-Black.svg b/assets/fonts/Mulish-Black.svg new file mode 100644 index 000000000..0f2be0509 --- /dev/null +++ b/assets/fonts/Mulish-Black.svg @@ -0,0 +1,8557 @@ + + + + +Created by FontForge 20201107 at Thu Sep 30 21:20:51 2021 + By +Copyright 2016 The Mulish Project Authors (https://github.com/googlefonts/mulish) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/fonts/Mulish-Black.ttf b/assets/fonts/Mulish-Black.ttf new file mode 100644 index 000000000..7c45457b3 Binary files /dev/null and b/assets/fonts/Mulish-Black.ttf differ diff --git a/assets/fonts/Mulish-Black.woff b/assets/fonts/Mulish-Black.woff new file mode 100644 index 000000000..eab41f8fa Binary files /dev/null and b/assets/fonts/Mulish-Black.woff differ diff --git a/assets/fonts/Mulish-Black.woff2 b/assets/fonts/Mulish-Black.woff2 new file mode 100644 index 000000000..edadd5f85 Binary files /dev/null and b/assets/fonts/Mulish-Black.woff2 differ diff --git a/assets/fonts/Mulish-BlackItalic.eot b/assets/fonts/Mulish-BlackItalic.eot new file mode 100644 index 000000000..ec7447c35 Binary files /dev/null and b/assets/fonts/Mulish-BlackItalic.eot differ diff --git a/assets/fonts/Mulish-BlackItalic.svg b/assets/fonts/Mulish-BlackItalic.svg new file mode 100644 index 000000000..278e54aae --- /dev/null +++ b/assets/fonts/Mulish-BlackItalic.svg @@ -0,0 +1,8605 @@ + + + + +Created by FontForge 20201107 at Thu Sep 30 21:20:51 2021 + By +Copyright 2016 The Mulish Project Authors (https://github.com/googlefonts/mulish) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/fonts/Mulish-BlackItalic.ttf b/assets/fonts/Mulish-BlackItalic.ttf new file mode 100644 index 000000000..7eda815bc Binary files /dev/null and b/assets/fonts/Mulish-BlackItalic.ttf differ diff --git a/assets/fonts/Mulish-BlackItalic.woff b/assets/fonts/Mulish-BlackItalic.woff new file mode 100644 index 000000000..cdc3d1897 Binary files /dev/null and b/assets/fonts/Mulish-BlackItalic.woff differ diff --git a/assets/fonts/Mulish-BlackItalic.woff2 b/assets/fonts/Mulish-BlackItalic.woff2 new file mode 100644 index 000000000..22cdb8aad Binary files /dev/null and b/assets/fonts/Mulish-BlackItalic.woff2 differ diff --git a/assets/fonts/Mulish-Bold.eot b/assets/fonts/Mulish-Bold.eot new file mode 100644 index 000000000..441567b43 Binary files /dev/null and b/assets/fonts/Mulish-Bold.eot differ diff --git a/assets/fonts/Mulish-Bold.svg b/assets/fonts/Mulish-Bold.svg new file mode 100644 index 000000000..8ae2e005c --- /dev/null +++ b/assets/fonts/Mulish-Bold.svg @@ -0,0 +1,8522 @@ + + + + +Created by FontForge 20201107 at Thu Sep 30 21:20:51 2021 + By +Copyright 2016 The Mulish Project Authors (https://github.com/googlefonts/mulish) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/fonts/Mulish-Bold.ttf b/assets/fonts/Mulish-Bold.ttf new file mode 100644 index 000000000..0c1eed735 Binary files /dev/null and b/assets/fonts/Mulish-Bold.ttf differ diff --git a/assets/fonts/Mulish-Bold.woff b/assets/fonts/Mulish-Bold.woff new file mode 100644 index 000000000..3be5ec72c Binary files /dev/null and b/assets/fonts/Mulish-Bold.woff differ diff --git a/assets/fonts/Mulish-Bold.woff2 b/assets/fonts/Mulish-Bold.woff2 new file mode 100644 index 000000000..279918a1e Binary files /dev/null and b/assets/fonts/Mulish-Bold.woff2 differ diff --git a/assets/fonts/Mulish-BoldItalic.eot b/assets/fonts/Mulish-BoldItalic.eot new file mode 100644 index 000000000..858b92902 Binary files /dev/null and b/assets/fonts/Mulish-BoldItalic.eot differ diff --git a/assets/fonts/Mulish-BoldItalic.svg b/assets/fonts/Mulish-BoldItalic.svg new file mode 100644 index 000000000..54cfcba59 --- /dev/null +++ b/assets/fonts/Mulish-BoldItalic.svg @@ -0,0 +1,8570 @@ + + + + +Created by FontForge 20201107 at Thu Sep 30 21:20:51 2021 + By +Copyright 2016 The Mulish Project Authors (https://github.com/googlefonts/mulish) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/fonts/Mulish-BoldItalic.ttf b/assets/fonts/Mulish-BoldItalic.ttf new file mode 100644 index 000000000..6f08e1512 Binary files /dev/null and b/assets/fonts/Mulish-BoldItalic.ttf differ diff --git a/assets/fonts/Mulish-BoldItalic.woff b/assets/fonts/Mulish-BoldItalic.woff new file mode 100644 index 000000000..3cae69dd0 Binary files /dev/null and b/assets/fonts/Mulish-BoldItalic.woff differ diff --git a/assets/fonts/Mulish-BoldItalic.woff2 b/assets/fonts/Mulish-BoldItalic.woff2 new file mode 100644 index 000000000..4b44b4d53 Binary files /dev/null and b/assets/fonts/Mulish-BoldItalic.woff2 differ diff --git a/assets/fonts/Mulish-ExtraBold.eot b/assets/fonts/Mulish-ExtraBold.eot new file mode 100644 index 000000000..846e1d5e4 Binary files /dev/null and b/assets/fonts/Mulish-ExtraBold.eot differ diff --git a/assets/fonts/Mulish-ExtraBold.svg b/assets/fonts/Mulish-ExtraBold.svg new file mode 100644 index 000000000..d0056d7fd --- /dev/null +++ b/assets/fonts/Mulish-ExtraBold.svg @@ -0,0 +1,8335 @@ + + + + +Created by FontForge 20201107 at Thu Sep 30 21:20:51 2021 + By +Copyright 2016 The Mulish Project Authors (https://github.com/googlefonts/mulish) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/fonts/Mulish-ExtraBold.ttf b/assets/fonts/Mulish-ExtraBold.ttf new file mode 100644 index 000000000..bf116994f Binary files /dev/null and b/assets/fonts/Mulish-ExtraBold.ttf differ diff --git a/assets/fonts/Mulish-ExtraBold.woff b/assets/fonts/Mulish-ExtraBold.woff new file mode 100644 index 000000000..ba2778c10 Binary files /dev/null and b/assets/fonts/Mulish-ExtraBold.woff differ diff --git a/assets/fonts/Mulish-ExtraBold.woff2 b/assets/fonts/Mulish-ExtraBold.woff2 new file mode 100644 index 000000000..9de553dd9 Binary files /dev/null and b/assets/fonts/Mulish-ExtraBold.woff2 differ diff --git a/assets/fonts/Mulish-ExtraBoldItalic.eot b/assets/fonts/Mulish-ExtraBoldItalic.eot new file mode 100644 index 000000000..7b3c9c271 Binary files /dev/null and b/assets/fonts/Mulish-ExtraBoldItalic.eot differ diff --git a/assets/fonts/Mulish-ExtraBoldItalic.svg b/assets/fonts/Mulish-ExtraBoldItalic.svg new file mode 100644 index 000000000..22911519f --- /dev/null +++ b/assets/fonts/Mulish-ExtraBoldItalic.svg @@ -0,0 +1,8384 @@ + + + + +Created by FontForge 20201107 at Thu Sep 30 21:20:51 2021 + By +Copyright 2016 The Mulish Project Authors (https://github.com/googlefonts/mulish) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/fonts/Mulish-ExtraBoldItalic.ttf b/assets/fonts/Mulish-ExtraBoldItalic.ttf new file mode 100644 index 000000000..33ae716ce Binary files /dev/null and b/assets/fonts/Mulish-ExtraBoldItalic.ttf differ diff --git a/assets/fonts/Mulish-ExtraBoldItalic.woff b/assets/fonts/Mulish-ExtraBoldItalic.woff new file mode 100644 index 000000000..6216d12d6 Binary files /dev/null and b/assets/fonts/Mulish-ExtraBoldItalic.woff differ diff --git a/assets/fonts/Mulish-ExtraBoldItalic.woff2 b/assets/fonts/Mulish-ExtraBoldItalic.woff2 new file mode 100644 index 000000000..65bb319b7 Binary files /dev/null and b/assets/fonts/Mulish-ExtraBoldItalic.woff2 differ diff --git a/assets/fonts/Mulish-ExtraLight.eot b/assets/fonts/Mulish-ExtraLight.eot new file mode 100644 index 000000000..36a95ed1e Binary files /dev/null and b/assets/fonts/Mulish-ExtraLight.eot differ diff --git a/assets/fonts/Mulish-ExtraLight.svg b/assets/fonts/Mulish-ExtraLight.svg new file mode 100644 index 000000000..4a5227555 --- /dev/null +++ b/assets/fonts/Mulish-ExtraLight.svg @@ -0,0 +1,7998 @@ + + + + +Created by FontForge 20201107 at Thu Sep 30 21:20:51 2021 + By +Copyright 2016 The Mulish Project Authors (https://github.com/googlefonts/mulish) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/fonts/Mulish-ExtraLight.ttf b/assets/fonts/Mulish-ExtraLight.ttf new file mode 100644 index 000000000..796400169 Binary files /dev/null and b/assets/fonts/Mulish-ExtraLight.ttf differ diff --git a/assets/fonts/Mulish-ExtraLight.woff b/assets/fonts/Mulish-ExtraLight.woff new file mode 100644 index 000000000..bebf4b601 Binary files /dev/null and b/assets/fonts/Mulish-ExtraLight.woff differ diff --git a/assets/fonts/Mulish-ExtraLight.woff2 b/assets/fonts/Mulish-ExtraLight.woff2 new file mode 100644 index 000000000..c2af72f0f Binary files /dev/null and b/assets/fonts/Mulish-ExtraLight.woff2 differ diff --git a/assets/fonts/Mulish-ExtraLightItalic.eot b/assets/fonts/Mulish-ExtraLightItalic.eot new file mode 100644 index 000000000..4bc9613c0 Binary files /dev/null and b/assets/fonts/Mulish-ExtraLightItalic.eot differ diff --git a/assets/fonts/Mulish-ExtraLightItalic.svg b/assets/fonts/Mulish-ExtraLightItalic.svg new file mode 100644 index 000000000..b55768d1b --- /dev/null +++ b/assets/fonts/Mulish-ExtraLightItalic.svg @@ -0,0 +1,8053 @@ + + + + +Created by FontForge 20201107 at Thu Sep 30 21:20:51 2021 + By +Copyright 2016 The Mulish Project Authors (https://github.com/googlefonts/mulish) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/fonts/Mulish-ExtraLightItalic.ttf b/assets/fonts/Mulish-ExtraLightItalic.ttf new file mode 100644 index 000000000..3e57b5451 Binary files /dev/null and b/assets/fonts/Mulish-ExtraLightItalic.ttf differ diff --git a/assets/fonts/Mulish-ExtraLightItalic.woff b/assets/fonts/Mulish-ExtraLightItalic.woff new file mode 100644 index 000000000..09162acb3 Binary files /dev/null and b/assets/fonts/Mulish-ExtraLightItalic.woff differ diff --git a/assets/fonts/Mulish-ExtraLightItalic.woff2 b/assets/fonts/Mulish-ExtraLightItalic.woff2 new file mode 100644 index 000000000..8cc5a0176 Binary files /dev/null and b/assets/fonts/Mulish-ExtraLightItalic.woff2 differ diff --git a/assets/fonts/Mulish-Italic-VariableFont_wght.ttf b/assets/fonts/Mulish-Italic-VariableFont_wght.ttf new file mode 100644 index 000000000..e5425c75e Binary files /dev/null and b/assets/fonts/Mulish-Italic-VariableFont_wght.ttf differ diff --git a/assets/fonts/Mulish-Italic.eot b/assets/fonts/Mulish-Italic.eot new file mode 100644 index 000000000..8cba90e5f Binary files /dev/null and b/assets/fonts/Mulish-Italic.eot differ diff --git a/assets/fonts/Mulish-Italic.svg b/assets/fonts/Mulish-Italic.svg new file mode 100644 index 000000000..c5ea370ae --- /dev/null +++ b/assets/fonts/Mulish-Italic.svg @@ -0,0 +1,8504 @@ + + + + +Created by FontForge 20201107 at Thu Sep 30 21:20:51 2021 + By +Copyright 2016 The Mulish Project Authors (https://github.com/googlefonts/mulish) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/fonts/Mulish-Italic.ttf b/assets/fonts/Mulish-Italic.ttf new file mode 100644 index 000000000..a42d8759e Binary files /dev/null and b/assets/fonts/Mulish-Italic.ttf differ diff --git a/assets/fonts/Mulish-Italic.woff b/assets/fonts/Mulish-Italic.woff new file mode 100644 index 000000000..2ef106b3a Binary files /dev/null and b/assets/fonts/Mulish-Italic.woff differ diff --git a/assets/fonts/Mulish-Italic.woff2 b/assets/fonts/Mulish-Italic.woff2 new file mode 100644 index 000000000..87c0a3bca Binary files /dev/null and b/assets/fonts/Mulish-Italic.woff2 differ diff --git a/assets/fonts/Mulish-Light.eot b/assets/fonts/Mulish-Light.eot new file mode 100644 index 000000000..006c5f9c9 Binary files /dev/null and b/assets/fonts/Mulish-Light.eot differ diff --git a/assets/fonts/Mulish-Light.svg b/assets/fonts/Mulish-Light.svg new file mode 100644 index 000000000..d6fa934d3 --- /dev/null +++ b/assets/fonts/Mulish-Light.svg @@ -0,0 +1,8099 @@ + + + + +Created by FontForge 20201107 at Thu Sep 30 21:20:51 2021 + By +Copyright 2016 The Mulish Project Authors (https://github.com/googlefonts/mulish) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/fonts/Mulish-Light.ttf b/assets/fonts/Mulish-Light.ttf new file mode 100644 index 000000000..f4d91c3b9 Binary files /dev/null and b/assets/fonts/Mulish-Light.ttf differ diff --git a/assets/fonts/Mulish-Light.woff b/assets/fonts/Mulish-Light.woff new file mode 100644 index 000000000..5294b7e1b Binary files /dev/null and b/assets/fonts/Mulish-Light.woff differ diff --git a/assets/fonts/Mulish-Light.woff2 b/assets/fonts/Mulish-Light.woff2 new file mode 100644 index 000000000..3279ca674 Binary files /dev/null and b/assets/fonts/Mulish-Light.woff2 differ diff --git a/assets/fonts/Mulish-LightItalic.eot b/assets/fonts/Mulish-LightItalic.eot new file mode 100644 index 000000000..971d6f558 Binary files /dev/null and b/assets/fonts/Mulish-LightItalic.eot differ diff --git a/assets/fonts/Mulish-LightItalic.svg b/assets/fonts/Mulish-LightItalic.svg new file mode 100644 index 000000000..930fae3e0 --- /dev/null +++ b/assets/fonts/Mulish-LightItalic.svg @@ -0,0 +1,8132 @@ + + + + +Created by FontForge 20201107 at Thu Sep 30 21:20:51 2021 + By +Copyright 2016 The Mulish Project Authors (https://github.com/googlefonts/mulish) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/fonts/Mulish-LightItalic.ttf b/assets/fonts/Mulish-LightItalic.ttf new file mode 100644 index 000000000..a4712b167 Binary files /dev/null and b/assets/fonts/Mulish-LightItalic.ttf differ diff --git a/assets/fonts/Mulish-LightItalic.woff b/assets/fonts/Mulish-LightItalic.woff new file mode 100644 index 000000000..ac0502480 Binary files /dev/null and b/assets/fonts/Mulish-LightItalic.woff differ diff --git a/assets/fonts/Mulish-LightItalic.woff2 b/assets/fonts/Mulish-LightItalic.woff2 new file mode 100644 index 000000000..5d5c34a1a Binary files /dev/null and b/assets/fonts/Mulish-LightItalic.woff2 differ diff --git a/assets/fonts/Mulish-Medium.eot b/assets/fonts/Mulish-Medium.eot new file mode 100644 index 000000000..f7efbbc9c Binary files /dev/null and b/assets/fonts/Mulish-Medium.eot differ diff --git a/assets/fonts/Mulish-Medium.svg b/assets/fonts/Mulish-Medium.svg new file mode 100644 index 000000000..4697c48e0 --- /dev/null +++ b/assets/fonts/Mulish-Medium.svg @@ -0,0 +1,8546 @@ + + + + +Created by FontForge 20201107 at Thu Sep 30 21:20:51 2021 + By +Copyright 2016 The Mulish Project Authors (https://github.com/googlefonts/mulish) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/fonts/Mulish-Medium.ttf b/assets/fonts/Mulish-Medium.ttf new file mode 100644 index 000000000..be50c5975 Binary files /dev/null and b/assets/fonts/Mulish-Medium.ttf differ diff --git a/assets/fonts/Mulish-Medium.woff b/assets/fonts/Mulish-Medium.woff new file mode 100644 index 000000000..fa5f95f84 Binary files /dev/null and b/assets/fonts/Mulish-Medium.woff differ diff --git a/assets/fonts/Mulish-Medium.woff2 b/assets/fonts/Mulish-Medium.woff2 new file mode 100644 index 000000000..2d3b8b5e5 Binary files /dev/null and b/assets/fonts/Mulish-Medium.woff2 differ diff --git a/assets/fonts/Mulish-MediumItalic.eot b/assets/fonts/Mulish-MediumItalic.eot new file mode 100644 index 000000000..e2918e740 Binary files /dev/null and b/assets/fonts/Mulish-MediumItalic.eot differ diff --git a/assets/fonts/Mulish-MediumItalic.svg b/assets/fonts/Mulish-MediumItalic.svg new file mode 100644 index 000000000..5555b2e08 --- /dev/null +++ b/assets/fonts/Mulish-MediumItalic.svg @@ -0,0 +1,8585 @@ + + + + +Created by FontForge 20201107 at Thu Sep 30 21:20:51 2021 + By +Copyright 2016 The Mulish Project Authors (https://github.com/googlefonts/mulish) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/fonts/Mulish-MediumItalic.ttf b/assets/fonts/Mulish-MediumItalic.ttf new file mode 100644 index 000000000..01cf746fd Binary files /dev/null and b/assets/fonts/Mulish-MediumItalic.ttf differ diff --git a/assets/fonts/Mulish-MediumItalic.woff b/assets/fonts/Mulish-MediumItalic.woff new file mode 100644 index 000000000..2118e8611 Binary files /dev/null and b/assets/fonts/Mulish-MediumItalic.woff differ diff --git a/assets/fonts/Mulish-MediumItalic.woff2 b/assets/fonts/Mulish-MediumItalic.woff2 new file mode 100644 index 000000000..108f5e159 Binary files /dev/null and b/assets/fonts/Mulish-MediumItalic.woff2 differ diff --git a/assets/fonts/Mulish-Regular.eot b/assets/fonts/Mulish-Regular.eot new file mode 100644 index 000000000..fafc27030 Binary files /dev/null and b/assets/fonts/Mulish-Regular.eot differ diff --git a/assets/fonts/Mulish-Regular.svg b/assets/fonts/Mulish-Regular.svg new file mode 100644 index 000000000..a7466b305 --- /dev/null +++ b/assets/fonts/Mulish-Regular.svg @@ -0,0 +1,8479 @@ + + + + +Created by FontForge 20201107 at Thu Sep 30 21:20:51 2021 + By +Copyright 2016 The Mulish Project Authors (https://github.com/googlefonts/mulish) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/fonts/Mulish-Regular.ttf b/assets/fonts/Mulish-Regular.ttf new file mode 100644 index 000000000..0971518b2 Binary files /dev/null and b/assets/fonts/Mulish-Regular.ttf differ diff --git a/assets/fonts/Mulish-Regular.woff b/assets/fonts/Mulish-Regular.woff new file mode 100644 index 000000000..06cb7e131 Binary files /dev/null and b/assets/fonts/Mulish-Regular.woff differ diff --git a/assets/fonts/Mulish-Regular.woff2 b/assets/fonts/Mulish-Regular.woff2 new file mode 100644 index 000000000..b4638eba9 Binary files /dev/null and b/assets/fonts/Mulish-Regular.woff2 differ diff --git a/assets/fonts/Mulish-SemiBold.eot b/assets/fonts/Mulish-SemiBold.eot new file mode 100644 index 000000000..c9dae1618 Binary files /dev/null and b/assets/fonts/Mulish-SemiBold.eot differ diff --git a/assets/fonts/Mulish-SemiBold.svg b/assets/fonts/Mulish-SemiBold.svg new file mode 100644 index 000000000..b3798ee32 --- /dev/null +++ b/assets/fonts/Mulish-SemiBold.svg @@ -0,0 +1,8550 @@ + + + + +Created by FontForge 20201107 at Thu Sep 30 21:20:51 2021 + By +Copyright 2016 The Mulish Project Authors (https://github.com/googlefonts/mulish) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/fonts/Mulish-SemiBold.ttf b/assets/fonts/Mulish-SemiBold.ttf new file mode 100644 index 000000000..9ac1fc8f2 Binary files /dev/null and b/assets/fonts/Mulish-SemiBold.ttf differ diff --git a/assets/fonts/Mulish-SemiBold.woff b/assets/fonts/Mulish-SemiBold.woff new file mode 100644 index 000000000..78668fbda Binary files /dev/null and b/assets/fonts/Mulish-SemiBold.woff differ diff --git a/assets/fonts/Mulish-SemiBold.woff2 b/assets/fonts/Mulish-SemiBold.woff2 new file mode 100644 index 000000000..68368e890 Binary files /dev/null and b/assets/fonts/Mulish-SemiBold.woff2 differ diff --git a/assets/fonts/Mulish-SemiBoldItalic.eot b/assets/fonts/Mulish-SemiBoldItalic.eot new file mode 100644 index 000000000..a9b2c8b76 Binary files /dev/null and b/assets/fonts/Mulish-SemiBoldItalic.eot differ diff --git a/assets/fonts/Mulish-SemiBoldItalic.svg b/assets/fonts/Mulish-SemiBoldItalic.svg new file mode 100644 index 000000000..d9c2e9965 --- /dev/null +++ b/assets/fonts/Mulish-SemiBoldItalic.svg @@ -0,0 +1,8599 @@ + + + + +Created by FontForge 20201107 at Thu Sep 30 21:20:51 2021 + By +Copyright 2016 The Mulish Project Authors (https://github.com/googlefonts/mulish) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/fonts/Mulish-SemiBoldItalic.ttf b/assets/fonts/Mulish-SemiBoldItalic.ttf new file mode 100644 index 000000000..71b3dc84b Binary files /dev/null and b/assets/fonts/Mulish-SemiBoldItalic.ttf differ diff --git a/assets/fonts/Mulish-SemiBoldItalic.woff b/assets/fonts/Mulish-SemiBoldItalic.woff new file mode 100644 index 000000000..4579c8639 Binary files /dev/null and b/assets/fonts/Mulish-SemiBoldItalic.woff differ diff --git a/assets/fonts/Mulish-SemiBoldItalic.woff2 b/assets/fonts/Mulish-SemiBoldItalic.woff2 new file mode 100644 index 000000000..fe6cd271d Binary files /dev/null and b/assets/fonts/Mulish-SemiBoldItalic.woff2 differ diff --git a/assets/fonts/Mulish-VariableFont_wght.ttf b/assets/fonts/Mulish-VariableFont_wght.ttf new file mode 100644 index 000000000..410f7aa63 Binary files /dev/null and b/assets/fonts/Mulish-VariableFont_wght.ttf differ diff --git a/assets/fonts/MulishExtraLight-Regular.eot b/assets/fonts/MulishExtraLight-Regular.eot new file mode 100644 index 000000000..b9fdbf2c5 Binary files /dev/null and b/assets/fonts/MulishExtraLight-Regular.eot differ diff --git a/assets/fonts/MulishExtraLight-Regular.svg b/assets/fonts/MulishExtraLight-Regular.svg new file mode 100644 index 000000000..02e979f39 --- /dev/null +++ b/assets/fonts/MulishExtraLight-Regular.svg @@ -0,0 +1,3643 @@ + + + + +Created by FontForge 20201107 at Thu Jun 3 17:20:51 2021 + By +Copyright 2016 The Mulish Project Authors (https://github.com/googlefonts/mulish) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/fonts/MulishExtraLight-Regular.woff b/assets/fonts/MulishExtraLight-Regular.woff new file mode 100644 index 000000000..5ff04734a Binary files /dev/null and b/assets/fonts/MulishExtraLight-Regular.woff differ diff --git a/assets/fonts/MulishExtraLight-Regular.woff2 b/assets/fonts/MulishExtraLight-Regular.woff2 new file mode 100644 index 000000000..10675e43e Binary files /dev/null and b/assets/fonts/MulishExtraLight-Regular.woff2 differ diff --git a/assets/fonts/mulish-v5-latin-regular.eot b/assets/fonts/mulish-v5-latin-regular.eot new file mode 100644 index 000000000..423bcb17a Binary files /dev/null and b/assets/fonts/mulish-v5-latin-regular.eot differ diff --git a/assets/fonts/mulish-v5-latin-regular.svg b/assets/fonts/mulish-v5-latin-regular.svg new file mode 100644 index 000000000..70341f98b --- /dev/null +++ b/assets/fonts/mulish-v5-latin-regular.svg @@ -0,0 +1,305 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/fonts/mulish-v5-latin-regular.ttf b/assets/fonts/mulish-v5-latin-regular.ttf new file mode 100644 index 000000000..541bb406e Binary files /dev/null and b/assets/fonts/mulish-v5-latin-regular.ttf differ diff --git a/assets/fonts/mulish-v5-latin-regular.woff b/assets/fonts/mulish-v5-latin-regular.woff new file mode 100644 index 000000000..700ec13f5 Binary files /dev/null and b/assets/fonts/mulish-v5-latin-regular.woff differ diff --git a/assets/fonts/mulish-v5-latin-regular.woff2 b/assets/fonts/mulish-v5-latin-regular.woff2 new file mode 100644 index 000000000..b244298bf Binary files /dev/null and b/assets/fonts/mulish-v5-latin-regular.woff2 differ diff --git a/assets/fonts/mulish-variablefont_wght.woff b/assets/fonts/mulish-variablefont_wght.woff new file mode 100644 index 000000000..fc425383a Binary files /dev/null and b/assets/fonts/mulish-variablefont_wght.woff differ diff --git a/assets/fonts/mulish-variablefont_wght.woff2 b/assets/fonts/mulish-variablefont_wght.woff2 new file mode 100644 index 000000000..8a233c6f9 Binary files /dev/null and b/assets/fonts/mulish-variablefont_wght.woff2 differ diff --git a/assets/images/carpentries-logo-sm.svg b/assets/images/carpentries-logo-sm.svg new file mode 100644 index 000000000..da70d40ee --- /dev/null +++ b/assets/images/carpentries-logo-sm.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/assets/images/carpentries-logo.svg b/assets/images/carpentries-logo.svg new file mode 100644 index 000000000..6cbe66500 --- /dev/null +++ b/assets/images/carpentries-logo.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/data-logo-sm.svg b/assets/images/data-logo-sm.svg new file mode 100644 index 000000000..6d4019ed5 --- /dev/null +++ b/assets/images/data-logo-sm.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/data-logo.svg b/assets/images/data-logo.svg new file mode 100644 index 000000000..c5949528e --- /dev/null +++ b/assets/images/data-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/dropdown-arrow.svg b/assets/images/dropdown-arrow.svg new file mode 100644 index 000000000..a12b04b34 --- /dev/null +++ b/assets/images/dropdown-arrow.svg @@ -0,0 +1,12 @@ + + + + +
+
+ + + + + +
+
+

Discussion

+

Last updated on 2023-07-26 | + + Edit this page

+ + + +
+ +
+ + + +

Choice of Image Processing Library

+

This lesson was originally designed to use OpenCV and the opencv-python +library (see +the last version of the lesson repository to use OpenCV).

+

In 2019-2020 the lesson was adapted to use scikit-image, as this library has +proven easier to install and enjoys more extensive documentation and +support.

+

Choice of Image Viewer

+

When the lesson was first adapted to use sckikit-image (see above), +skimage.viewer.ImageViewer was used to inspect images. This +viewer is deprecated and the lesson maintainers chose to leverage +matplotlib.pyplot.imshow with the pan/zoom and +mouse-location tools built into the Matplotlib +GUI. The ipympl +package is required to enable the interactive features of Matplotlib +in Jupyter notebooks and in Jupyter Lab. This package is included in the +setup instructions, and the backend can be enabled using the +%matplotlib widget magic.

+

The maintainers discussed the possibility of using napari as an image viewer in the lesson, +acknowledging its growing popularity and some of the advantages it holds +over the Matplotlib-based approach, especially for working with image +data in more than two dimensions. However, at the time of discussion, +napari was still in an alpha state of development, and could not be +relied on for easy and error-free installation on all operating systems, +which makes it less well-suited to use in an official Data Carpentry +curriculum.

+

The lesson Maintainers and/or Curriculum Advisory Committee (when it +exists) will monitor the progress of napari and other image viewers, and +may opt to adopt a new platform in future.

+
+
+ + +
+
+ + + diff --git a/docsearch.css b/docsearch.css new file mode 100644 index 000000000..e5f1fe1df --- /dev/null +++ b/docsearch.css @@ -0,0 +1,148 @@ +/* Docsearch -------------------------------------------------------------- */ +/* + Source: https://github.com/algolia/docsearch/ + License: MIT +*/ + +.algolia-autocomplete { + display: block; + -webkit-box-flex: 1; + -ms-flex: 1; + flex: 1 +} + +.algolia-autocomplete .ds-dropdown-menu { + width: 100%; + min-width: none; + max-width: none; + padding: .75rem 0; + background-color: #fff; + background-clip: padding-box; + border: 1px solid rgba(0, 0, 0, .1); + box-shadow: 0 .5rem 1rem rgba(0, 0, 0, .175); +} + +@media (min-width:768px) { + .algolia-autocomplete .ds-dropdown-menu { + width: 175% + } +} + +.algolia-autocomplete .ds-dropdown-menu::before { + display: none +} + +.algolia-autocomplete .ds-dropdown-menu [class^=ds-dataset-] { + padding: 0; + background-color: rgb(255,255,255); + border: 0; + max-height: 80vh; +} + +.algolia-autocomplete .ds-dropdown-menu .ds-suggestions { + margin-top: 0 +} + +.algolia-autocomplete .algolia-docsearch-suggestion { + padding: 0; + overflow: visible +} + +.algolia-autocomplete .algolia-docsearch-suggestion--category-header { + padding: .125rem 1rem; + margin-top: 0; + font-size: 1.3em; + font-weight: 500; + color: #00008B; + border-bottom: 0 +} + +.algolia-autocomplete .algolia-docsearch-suggestion--wrapper { + float: none; + padding-top: 0 +} + +.algolia-autocomplete .algolia-docsearch-suggestion--subcategory-column { + float: none; + width: auto; + padding: 0; + text-align: left +} + +.algolia-autocomplete .algolia-docsearch-suggestion--content { + float: none; + width: auto; + padding: 0 +} + +.algolia-autocomplete .algolia-docsearch-suggestion--content::before { + display: none +} + +.algolia-autocomplete .ds-suggestion:not(:first-child) .algolia-docsearch-suggestion--category-header { + padding-top: .75rem; + margin-top: .75rem; + border-top: 1px solid rgba(0, 0, 0, .1) +} + +.algolia-autocomplete .ds-suggestion .algolia-docsearch-suggestion--subcategory-column { + display: block; + padding: .1rem 1rem; + margin-bottom: 0.1; + font-size: 1.0em; + font-weight: 400 + /* display: none */ +} + +.algolia-autocomplete .algolia-docsearch-suggestion--title { + display: block; + padding: .25rem 1rem; + margin-bottom: 0; + font-size: 0.9em; + font-weight: 400 +} + +.algolia-autocomplete .algolia-docsearch-suggestion--text { + padding: 0 1rem .5rem; + margin-top: -.25rem; + font-size: 0.8em; + font-weight: 400; + line-height: 1.25 +} + +.algolia-autocomplete .algolia-docsearch-footer { + width: 110px; + height: 20px; + z-index: 3; + margin-top: 10.66667px; + float: right; + font-size: 0; + line-height: 0; +} + +.algolia-autocomplete .algolia-docsearch-footer--logo { + background-image: url("data:image/svg+xml;utf8,"); + background-repeat: no-repeat; + background-position: 50%; + background-size: 100%; + overflow: hidden; + text-indent: -9000px; + width: 100%; + height: 100%; + display: block; + transform: translate(-8px); +} + +.algolia-autocomplete .algolia-docsearch-suggestion--highlight { + color: #FF8C00; + background: rgba(232, 189, 54, 0.1) +} + + +.algolia-autocomplete .algolia-docsearch-suggestion--text .algolia-docsearch-suggestion--highlight { + box-shadow: inset 0 -2px 0 0 rgba(105, 105, 105, .5) +} + +.algolia-autocomplete .ds-suggestion.ds-cursor .algolia-docsearch-suggestion--content { + background-color: rgba(192, 192, 192, .15) +} diff --git a/docsearch.js b/docsearch.js new file mode 100644 index 000000000..b35504cd3 --- /dev/null +++ b/docsearch.js @@ -0,0 +1,85 @@ +$(function() { + + // register a handler to move the focus to the search bar + // upon pressing shift + "/" (i.e. "?") + $(document).on('keydown', function(e) { + if (e.shiftKey && e.keyCode == 191) { + e.preventDefault(); + $("#search-input").focus(); + } + }); + + $(document).ready(function() { + // do keyword highlighting + /* modified from https://jsfiddle.net/julmot/bL6bb5oo/ */ + var mark = function() { + + var referrer = document.URL ; + var paramKey = "q" ; + + if (referrer.indexOf("?") !== -1) { + var qs = referrer.substr(referrer.indexOf('?') + 1); + var qs_noanchor = qs.split('#')[0]; + var qsa = qs_noanchor.split('&'); + var keyword = ""; + + for (var i = 0; i < qsa.length; i++) { + var currentParam = qsa[i].split('='); + + if (currentParam.length !== 2) { + continue; + } + + if (currentParam[0] == paramKey) { + keyword = decodeURIComponent(currentParam[1].replace(/\+/g, "%20")); + } + } + + if (keyword !== "") { + $(".contents").unmark({ + done: function() { + $(".contents").mark(keyword); + } + }); + } + } + }; + + mark(); + }); +}); + +/* Search term highlighting ------------------------------*/ + +function matchedWords(hit) { + var words = []; + + var hierarchy = hit._highlightResult.hierarchy; + // loop to fetch from lvl0, lvl1, etc. + for (var idx in hierarchy) { + words = words.concat(hierarchy[idx].matchedWords); + } + + var content = hit._highlightResult.content; + if (content) { + words = words.concat(content.matchedWords); + } + + // return unique words + var words_uniq = [...new Set(words)]; + return words_uniq; +} + +function updateHitURL(hit) { + + var words = matchedWords(hit); + var url = ""; + + if (hit.anchor) { + url = hit.url_without_anchor + '?q=' + escape(words.join(" ")) + '#' + hit.anchor; + } else { + url = hit.url + '?q=' + escape(words.join(" ")); + } + + return url; +} diff --git a/edge-detection.html b/edge-detection.html new file mode 100644 index 000000000..2ec212b6c --- /dev/null +++ b/edge-detection.html @@ -0,0 +1,860 @@ + +Image Processing with Python: Extra Episode: Edge Detection +
+
+ + + + + +
+
+

Extra Episode: Edge Detection

+

Last updated on 2026-03-20 | + + Edit this page

+ + + +
+ +
+ + + +
+

Overview

+
+
+
+
+

Questions

+
  • How can we automatically detect the edges of the objects in an +image?
  • +
+
+
+
+
+
+

Objectives

+
  • Apply Canny edge detection to an image.
  • +
  • Explain how we can use sliders to expedite finding appropriate +parameter values for our scikit-image function calls.
  • +
  • Create scikit-image windows with sliders and associated callback +functions.
  • +
+
+
+
+
+

In this episode, we will learn how to use scikit-image functions to +apply edge detection to an image. In edge detection, we find +the boundaries or edges of objects in an image, by determining where the +brightness of the image changes dramatically. Edge detection can be used +to extract the structure of objects in an image. If we are interested in +the number, size, shape, or relative location of objects in an image, +edge detection allows us to focus on the parts of the image most +helpful, while ignoring parts of the image that will not help us.

+

For example, once we have found the edges of the objects in the image +(or once we have converted the image to binary using thresholding), we +can use that information to find the image contours, which we +will learn about in the +Connected Component Analysis episode. With the contours, we +can do things like counting the number of objects in the image, measure +the size of the objects, classify the shapes of the objects, and so +on.

+

As was the case for blurring and thresholding, there are several +different methods in scikit-image that can be used for edge detection, +so we will examine only one in detail.

+

Introduction to edge detection

+

To begin our introduction to edge detection, let us look at an image +with a very simple edge - this grayscale image of two overlapped pieces +of paper, one black and and one white:

+
Black and white image

The obvious edge in the image is the vertical line between the black +paper and the white paper. To our eyes, there is a quite sudden change +between the black pixels and the white pixels. But, at a pixel-by-pixel +level, is the transition really that sudden?

+

If we zoom in on the edge more closely, as in this image, we can see +that the edge between the black and white areas of the image is not a +clear-cut line.

+
Black and white edge pixels

We can learn more about the edge by examining the colour values of +some of the pixels. Imagine a short line segment, halfway down the image +and straddling the edge between the black and white paper. This plot +shows the pixel values (between 0 and 255, since this is a grayscale +image) for forty pixels spanning the transition from black to white.

+
Gradient near transition

It is obvious that the “edge” here is not so sudden! So, any +scikit-image method to detect edges in an image must be able to decide +where the edge is, and place appropriately-coloured pixels in that +location.

+

Canny edge detection

+

Our edge detection method in this workshop is Canny edge +detection, created by John Canny in 1986. This method uses a series +of steps, some incorporating other types of edge detection. The +skimage.feature.canny() function performs the following +steps:

+
  1. A Gaussian blur (that is characterised by the sigma +parameter, see Blurring Images +is applied to remove noise from the image. (So if we are doing edge +detection via this function, we should not perform our own blurring +step.)
  2. +
  3. Sobel edge detection is performed on both the cx and ry dimensions, +to find the intensity gradients of the edges in the image. Sobel edge +detection computes the derivative of a curve fitting the gradient +between light and dark areas in an image, and then finds the peak of the +derivative, which is interpreted as the location of an edge pixel.
  4. +
  5. Pixels that would be highlighted, but seem too far from any edge, +are removed. This is called non-maximum suppression, and the +result is edge lines that are thinner than those produced by other +methods.
  6. +
  7. A double threshold is applied to determine potential edges. Here +extraneous pixels caused by noise or milder colour variation than +desired are eliminated. If a pixel’s gradient value - based on the Sobel +differential - is above the high threshold value, it is considered a +strong candidate for an edge. If the gradient is below the low threshold +value, it is turned off. If the gradient is in between, the pixel is +considered a weak candidate for an edge pixel.
  8. +
  9. Final detection of edges is performed using hysteresis. +Here, weak candidate pixels are examined, and if they are connected to +strong candidate pixels, they are considered to be edge pixels; the +remaining, non-connected weak candidates are turned off.
  10. +

For a user of the skimage.feature.canny() edge detection +function, there are three important parameters to pass in: +sigma for the Gaussian filter in step one and the low and +high threshold values used in step four of the process. These values +generally are determined empirically, based on the contents of the +image(s) to be processed.

+

The following program illustrates how the +skimage.feature.canny() method can be used to detect the +edges in an image. We will execute the program on the +data/shapes-01.jpg image, which we used before in the Thresholding episode:

+
coloured shapes

We are interested in finding the edges of the shapes in the image, +and so the colours are not important. Our strategy will be to read the +image as grayscale, and then apply Canny edge detection. Note that when +reading the image with iio.imread(..., mode="L") the image +is converted to a grayscale image of same dtype.

+

This program takes three command-line arguments: the filename of the +image to process, and then two arguments related to the double +thresholding in step four of the Canny edge detection process. These are +the low and high threshold values for that step. After the required +libraries are imported, the program reads the command-line arguments and +saves them in their respective variables.

+
+

PYTHON +

+
"""Python script to demonstrate Canny edge detection.
+
+usage: python CannyEdge.py <filename> <sigma> <low_threshold> <high_threshold>
+"""
+import imageio.v3 as iio
+import matplotlib.pyplot as plt
+import skimage.feature
+import sys
+
+# read command-line arguments
+filename = sys.argv[1]
+sigma = float(sys.argv[2])
+low_threshold = float(sys.argv[3])
+high_threshold = float(sys.argv[4])
+
+

Next, the original images is read, in grayscale, and displayed.

+
+

PYTHON +

+
# load and display original image as grayscale
+image = iio.imread(uri=filename, mode="L")
+plt.imshow(image)
+
+

Then, we apply Canny edge detection with this function call:

+
+

PYTHON +

+
edges = skimage.feature.canny(
+    image=image,
+    sigma=sigma,
+    low_threshold=low_threshold,
+    high_threshold=high_threshold,
+)
+
+

As we are using it here, the skimage.feature.canny() +function takes four parameters. The first parameter is the input image. +The sigma parameter determines the amount of Gaussian +smoothing that is applied to the image. The next two parameters are the +low and high threshold values for the fourth step of the process.

+

The result of this call is a binary image. In the image, the edges +detected by the process are white, while everything else is black.

+

Finally, the program displays the edges image, showing +the edges that were found in the original.

+
+

PYTHON +

+
# display edges
+skimage.io.imshow(edges)
+
+

Here is the result, for the coloured shape image above, with sigma +value 2.0, low threshold value 0.1 and high threshold value 0.3:

+
Output file of Canny edge detection

Note that the edge output shown in a scikit-image window may look +significantly worse than the image would look if it were saved to a file +due to resampling artefacts in the interactive image viewer. The image +above is the edges of the junk image, saved in a PNG file. Here is how +the same image looks when displayed in a scikit-image output window:

+
Output window of Canny edge detection

Interacting with the image viewer using viewer plugins

+

As we have seen, for a user of the +skimage.feature.canny() edge detection function, three +important parameters to pass in are sigma, and the low and high +threshold values used in step four of the process. These values +generally are determined empirically, based on the contents of the +image(s) to be processed.

+

Here is an image of some glass beads that we can use as input into a +Canny edge detection program:

+
Beads image

We could use the code/edge-detection/CannyEdge.py +program above to find edges in this image. To find acceptable values for +the thresholds, we would have to run the program over and over again, +trying different threshold values and examining the resulting image, +until we find a combination of parameters that works best for the +image.

+

Or, we can write a Python program and create a viewer plugin +that uses scikit-image sliders, that allow us to vary the +function parameters while the program is running. In other words, we can +write a program that presents us with a window like this:

+
Canny UI

Then, when we run the program, we can use the sliders to vary the +values of the sigma and threshold parameters until we are satisfied with +the results. After we have determined suitable values, we can use the +simpler program to utilise the parameters without bothering with the +user interface and sliders.

+

Here is a Python program that shows how to apply Canny edge +detection, and how to add sliders to the user interface. There are four +parts to this program, making it a bit (but only a bit) more +complicated than the programs we have looked at so far. The added +complexity comes from setting up the sliders for the parameters that +were previously read from the command line: In particular, we have +added

+
  • The canny() filter function that returns an edge +image,
  • +
  • The cannyPlugin plugin object, to which we add
  • +
  • The sliders for sigma, and low and high +threshold values, and
  • +
  • The main program, i.e., the code that is executed when the program +runs.
  • +

We will look at the main program part first, and then return to +writing the plugin. The first several lines of the main program are +easily recognizable at this point: saving the command-line argument, +reading the image in grayscale, and creating a window.

+
+

PYTHON +

+
"""Python script to demonstrate Canny edge detection with sliders to adjust the thresholds.
+
+usage: python CannyTrack.py <filename>
+"""
+import imageio.v3 as iio
+import matplotlib.pyplot as plt
+import skimage.feature
+import skimage.viewer
+import sys
+
+
+filename = sys.argv[1]
+image = iio.imread(uri=filename, mode="L")
+viewer = plt.imshow(image)
+
+

The skimage.viewer.plugins.Plugin class is designed to +manipulate images. It takes an image_filter argument in the +constructor that should be a function. This function should produce a +new image as an output, given an image as the first argument, which then +will be automatically displayed in the image viewer.

+
+

PYTHON +

+
# Create the plugin and give it a name
+canny_plugin = skimage.viewer.plugins.Plugin(image_filter=skimage.feature.canny)
+canny_plugin.name = "Canny Filter Plugin"
+
+

We want to interactively modify the parameters of the filter function +interactively. scikit-image allows us to further enrich the plugin by +adding widgets, like skimage.viewer.widgets.Slider, +skimage.viewer.widgets.CheckBox, +skimage.viewer.widgets.ComboBox. Whenever a widget +belonging to the plugin is updated, the filter function is called with +the updated parameters. This function is also called a callback +function. The following code adds sliders for sigma, +low_threshold and high_thresholds.

+
+

PYTHON +

+
# Add sliders for the parameters
+canny_plugin += skimage.viewer.widgets.Slider(
+    name="sigma", low=0.0, high=7.0, value=2.0
+)
+canny_plugin += skimage.viewer.widgets.Slider(
+    name="low_threshold", low=0.0, high=1.0, value=0.1
+)
+canny_plugin += skimage.viewer.widgets.Slider(
+    name="high_threshold", low=0.0, high=1.0, value=0.2
+)
+
+

A slider is a widget that lets you choose a number by dragging a +handle along a line. On the left side of the line, we have the lowest +value, on the right side the highest value that can be chosen. The range +of values in between is distributed equally along this line. All three +sliders are constructed in the same way: The first argument is the name +of the parameter that is tweaked by the slider. With the arguments +low, and high, we supply the limits for the +range of numbers that is represented by the slider. The +value argument specifies the initial value of that +parameter, so where the handle is located when the plugin is started. +Adding the slider to the plugin makes the values available as parameters +to the filter_function.

+
+
+ +
+Callout +
+

How does the plugin know how to call the +filter function with the parameters?

+
+

The filter function will be called with the slider parameters +according to their names as keyword arguments. So it +is very important to name the sliders appropriately.

+
+
+
+

Finally, we add the plugin the viewer and display the resulting user +interface:

+
+

PYTHON +

+
# add the plugin to the viewer and show the window
+viewer += canny_plugin
+viewer.show()
+
+

Here is the result of running the preceding program on the beads +image, with a sigma value 1.0, low threshold value 0.1 and high +threshold value 0.3. The image shows the edges in an output file.

+
Beads edges (file)
+
+ +
+Challenge +
+

Applying Canny edge detection to another image +(5 min)

+
+

Now, run the program above on the image of coloured shapes, +data/shapes-01.jpg. Use a sigma of 1.0 and adjust low and +high threshold sliders to produce an edge image that looks like +this:

+
coloured shape edges

What values for the low and high threshold values did you use to +produce an image similar to the one above?

+
+
+
+
+
+ +
+
+

The coloured shape edge image above was produced with a low threshold +value of 0.05 and a high threshold value of 0.07. You may be able to +achieve similar results with other threshold values.

+
+
+
+
+
+
+ +
+Challenge +
+

Using sliders for thresholding (30 min)

+
+

Now, let us apply what we know about creating sliders to another, +similar situation. Consider this image of a collection of maize +seedlings, and suppose we wish to use simple fixed-level thresholding to +mask out everything that is not part of one of the plants.

+
Maize roots image

To perform the thresholding, we could first create a histogram, then +examine it, and select an appropriate threshold value. Here, however, +let us create an application with a slider to set the threshold value. +Create a program that reads in the image, displays it in a window with a +slider, and allows the slider value to vary the threshold value used. +You will find the image at +data/maize-roots-grayscale.jpg.

+
+
+
+
+
+ +
+
+

Here is a program that uses a slider to vary the threshold value used +in a simple, fixed-level thresholding process.

+
+

PYTHON +

+
"""Python program to use a slider to control fixed-level thresholding value.
+
+usage: python interactive_thresholding.py <filename>
+"""
+
+import imageio.v3 as iio
+import skimage
+import skimage.viewer
+import sys
+
+filename = sys.argv[1]
+
+
+def filter_function(image, sigma, threshold):
+    masked = image.copy()
+    masked[skimage.filters.gaussian(image, sigma=sigma) <= threshold] = 0
+    return masked
+
+
+smooth_threshold_plugin = skimage.viewer.plugins.Plugin(
+    image_filter=filter_function
+)
+
+smooth_threshold_plugin.name = "Smooth and Threshold Plugin"
+
+smooth_threshold_plugin += skimage.viewer.widgets.Slider(
+    "sigma", low=0.0, high=7.0, value=1.0
+)
+smooth_threshold_plugin += skimage.viewer.widgets.Slider(
+    "threshold", low=0.0, high=1.0, value=0.5
+)
+
+image = iio.imread(uri=filename, mode="L")
+
+viewer = skimage.viewer.ImageViewer(image=image)
+viewer += smooth_threshold_plugin
+viewer.show()
+
+

Here is the output of the program, blurring with a sigma of 1.5 and a +threshold value of 0.45:

+
Thresholded maize roots
+
+
+
+

Keep this plugin technique in your image processing “toolbox.” You +can use sliders (or other interactive elements, see the +scikit-image documentation) to vary other kinds of parameters, such +as sigma for blurring, binary thresholding values, and so on. A few +minutes developing a program to tweak parameters like this can save you +the hassle of repeatedly running a program from the command line with +different parameter values. Furthermore, scikit-image already comes with +a few viewer plugins that you can check out in the +documentation.

+

Other edge detection functions

+

As with blurring, there are other options for finding edges in +skimage. These include skimage.filters.sobel(), which you +will recognise as part of the Canny method. Another choice is +skimage.filters.laplace().

+
+
+ +
+Key Points +
+
+
  • The skimage.viewer.ImageViewer is extended using a +skimage.viewer.plugins.Plugin.
  • +
  • We supply a filter function callback when creating a Plugin.
  • +
  • Parameters of the callback function are manipulated interactively by +creating sliders with the skimage.viewer.widgets.slider() +function and adding them to the plugin.
  • +
+
+
+
+
+ + +
+
+ + + diff --git a/episodes/01-introduction.md b/episodes/01-introduction.md deleted file mode 100644 index 7cd87bc4f..000000000 --- a/episodes/01-introduction.md +++ /dev/null @@ -1,127 +0,0 @@ ---- -title: Introduction -teaching: 5 -exercises: 0 ---- - -::::::::::::::::::::::::::::::::::::::: objectives - -- Recognise scientific questions that could be solved with image processing / computer vision. -- Recognise morphometric problems (those dealing with the number, size, or shape of the objects in an image). - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -:::::::::::::::::::::::::::::::::::::::: questions - -- What sort of scientific questions can we answer with image processing / computer vision? -- What are morphometric problems? - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -As computer systems have become faster and more powerful, -and cameras and other imaging systems have become commonplace -in many other areas of life, -the need has grown for researchers to be able to -process and analyse image data. -Considering the large volumes of data that can be involved - -high-resolution images that take up a lot of disk space/virtual memory, -and/or collections of many images that must be processed together - -and the time-consuming and error-prone nature of manual processing, -it can be advantageous or even necessary for this processing and analysis -to be automated as a computer program. - -This lesson introduces an open source toolkit for processing image data: -the Python programming language -and [the *scikit-image* (`skimage`) library](https://scikit-image.org/). -With careful experimental design, -Python code can be a powerful instrument in answering many different kinds of questions. - -## Uses of Image Processing in Research - -Automated processing can be used to analyse many different properties of an image, -including the distribution and change in colours in the image, -the number, size, position, orientation, and shape of objects in the image, -and even - when combined with machine learning techniques for object recognition - -the type of objects in the image. - -Some examples of image processing methods applied in research include: - -- [Imaging a black hole](https://iopscience.iop.org/article/10.3847/2041-8213/ab0e85) -- [Segmentation of liver and vessels from CT images](https://doi.org/10.1016/j.cmpb.2017.12.008) -- [Monitoring wading birds in the Everglades using drones](https://dx.doi.org/10.1002/rse2.421) ([Blog article summarizing the paper](https://jabberwocky.weecology.org/2024/07/29/monitoring-wading-birds-in-near-real-time-using-drones-and-computer-vision/)) -- [Estimating the population of emperor penguins](https://www.ncbi.nlm.nih.gov/pmc/articles/PMC3325796/) -- [Global-scale analysis of marine plankton diversity](https://www.cell.com/cell/fulltext/S0092-8674\(19\)31124-9) - -With this lesson, -we aim to provide a thorough grounding in the fundamental concepts and skills -of working with image data in Python. -Most of the examples used in this lesson focus on -one particular class of image processing technique, *morphometrics*, -but what you will learn can be used to solve a much wider range of problems. - -## Morphometrics - -Morphometrics involves counting the number of objects in an image, -analyzing the size of the objects, -or analyzing the shape of the objects. -For example, we might be interested in automatically counting -the number of bacterial colonies growing in a Petri dish, -as shown in this image: - -![](fig/colonies-01.jpg){alt='Bacteria colony'} - -We could use image processing to find the colonies, count them, -and then highlight their locations on the original image, -resulting in an image like this: - -![](fig/colony-mask.png){alt='Colonies counted'} - -::::::::::::::::::::::::::::::::::::::::: callout - -## Why write a program to do that? - -Note that you can easily manually count the number of bacteria colonies -shown in the morphometric example above. -Why should we learn how to write a Python program to do a task -we could easily perform with our own eyes? -There are at least two reasons to learn how to perform tasks like these -with Python and scikit-image: - -1. What if there are many more bacteria colonies in the Petri dish? - For example, suppose the image looked like this: - -![](fig/colonies-03.jpg){alt='Bacteria colony'} - -Manually counting the colonies in that image would present more of a challenge. -A Python program using scikit-image could count the number of colonies more accurately, -and much more quickly, than a human could. - -2. What if you have hundreds, or thousands, of images to consider? - Imagine having to manually count colonies on several thousand images - like those above. - A Python program using scikit-image could move through all of the images in seconds; - how long would a graduate student require to do the task? - Which process would be more accurate and repeatable? - -As you can see, the simple image processing / computer vision techniques you -will learn during this workshop can be very valuable tools for scientific -research. - - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -As we move through this workshop, -we will learn image analysis methods useful for many different scientific problems. -These will be linked together -and applied to a real problem in the final end-of-workshop -[capstone challenge](09-challenges.md). - -Let's get started, -by learning some basics about how images are represented and stored digitally. - -:::::::::::::::::::::::::::::::::::::::: keypoints - -- Simple Python and scikit-image techniques can be used to solve genuine image analysis problems. -- Morphometric problems involve the number, shape, and / or size of the objects in an image. - -:::::::::::::::::::::::::::::::::::::::::::::::::: diff --git a/episodes/02-image-basics.md b/episodes/02-image-basics.md deleted file mode 100644 index cb6cd2bbb..000000000 --- a/episodes/02-image-basics.md +++ /dev/null @@ -1,1127 +0,0 @@ ---- -title: Image Basics -teaching: 20 -exercises: 5 ---- - -::::::::::::::::::::::::::::::::::::::: objectives - -- Define the terms bit, byte, kilobyte, megabyte, etc. -- Explain how a digital image is composed of pixels. -- Recommend using imageio (resp. scikit-image) for I/O (resp. image processing) tasks. -- Explain how images are stored in NumPy arrays. -- Explain the left-hand coordinate system used in digital images. -- Explain the RGB additive colour model used in digital images. -- Explain the order of the three colour values in scikit-image images. -- Explain the characteristics of the BMP, JPEG, and TIFF image formats. -- Explain the difference between lossy and lossless compression. -- Explain the advantages and disadvantages of compressed image formats. -- Explain what information could be contained in image metadata. - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -:::::::::::::::::::::::::::::::::::::::: questions - -- How are images represented in digital format? - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -The images we see on hard copy, view with our electronic devices, -or process with our programs are represented and stored in the computer -as numeric abstractions, approximations of what we see with our eyes in the real world. -Before we begin to learn how to process images with Python programs, -we need to spend some time understanding how these abstractions work. - -::::::::::::::::::::::::::::::::::::::::: callout - -Feel free to make use of the [available cheat-sheet](./files/cheatsheet.html) as a guide for the rest of the course material. View it online, share it, or print the [PDF](./files/cheatsheet.pdf)! - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -## Pixels - -It is important to realise that images are stored as rectangular arrays -of hundreds, thousands, or millions of discrete "picture elements," -otherwise known as *pixels*. -Each pixel can be thought of as a single square point of coloured light. - -For example, consider this image of a maize seedling, -with a square area designated by a red box: - -![](fig/maize-seedling-original.jpg){alt='Original size image'} - -Now, if we zoomed in close enough to see the pixels in the red box, -we would see something like this: - -![](fig/maize-seedling-enlarged.jpg){alt='Enlarged image area'} - -Note that each square in the enlarged image area - each pixel - -is all one colour, -but that each pixel can have a different colour from its neighbors. -Viewed from a distance, -these pixels seem to blend together to form the image we see. - -Real-world images are typically made up of a vast number of pixels, -and each of these pixels is one of potentially millions of colours. -While we will deal with pictures of such complexity in this lesson, -let's start our exploration with just 15 pixels in a 5 x 3 matrix with 2 colours, -and work our way up to that complexity. - -::::::::::::::::::::::::::::::::::::::::: callout - -## Matrices, arrays, images and pixels - -A **matrix** is a mathematical concept - numbers evenly arranged in a rectangle. This can be a two-dimensional rectangle, -like the shape of the screen you're looking at now. Or it could be a three-dimensional equivalent, a cuboid, or have -even more dimensions, but always keeping the evenly spaced arrangement of numbers. In computing, an **array** refers -to a structure in the computer's memory where data is stored in evenly spaced **elements**. This is strongly analogous -to a matrix. A NumPy array is a **type** of variable (a simpler example of a type is an integer). For our purposes, -the distinction between matrices and arrays is not important, we don't really care how the computer arranges our data -in its memory. The important thing is that the computer stores values describing the pixels in images, as arrays. And -the terms matrix and array will be used interchangeably. - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -## Loading images - -As noted, images we want to analyze (process) with Python are loaded into arrays. -There are multiple ways to load images. In this lesson, we use imageio, a Python -library for reading (loading) and writing (saving) image data, and more specifically -its version 3. But, really, we could use any image loader which would return a -NumPy array. - -```python -"""Python library for reading and writing images.""" - -import imageio.v3 as iio -``` - -The `v3` module of imageio (`imageio.v3`) is imported as `iio` (see note in -the next section). -Version 3 of imageio has the benefit of supporting nD (multidimensional) image data -natively (think of volumes, movies). - -Let us load our image data from disk using -the `imread` function from the `imageio.v3` module. - -```python -eight = iio.imread(uri="data/eight.tif") -print(type(eight)) -``` - -```output - -``` - -Note that, using the same image loader or a different one, we could also read in -remotely hosted data. - -::::::::::::::::::::::::::::::::::::::::: callout - -## Why not use `skimage.io.imread()`? - -The scikit-image library has its own function to read an image, -so you might be asking why we don't use it here. -Actually, `skimage.io.imread()` uses `iio.imread()` internally when loading an image into Python. -It is certainly something you may use as you see fit in your own code. -In this lesson, we use the imageio library to read or write images, -while scikit-image is dedicated to performing operations on the images. -Using imageio gives us more flexibility, especially when it comes to -handling metadata. - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -::::::::::::::::::::::::::::::::::::::::: callout - -## Beyond NumPy arrays - -Beyond NumPy arrays, there exist other types of variables which are array-like. Notably, -[pandas.DataFrame](https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.html) -and [xarray.DataArray](https://docs.xarray.dev/en/stable/generated/xarray.DataArray.html) -can hold labeled, tabular data. -These are not natively supported in scikit-image, the scientific toolkit we use -in this lesson for processing image data. However, data stored in these types can -be converted to `numpy.ndarray` with certain assumptions -(see `pandas.DataFrame.to_numpy()` and `xarray.DataArray.data`). Particularly, -these conversions ignore the sampling coordinates (`DataFrame.index`, -`DataFrame.columns`, or `DataArray.coords`), which may result in misrepresented data, -for instance, when the original data points are irregularly spaced. - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -## Working with pixels - -First, let us add the necessary imports: - -```python -"""Python libraries for learning and performing image processing.""" - -import ipympl -import matplotlib.pyplot as plt -import numpy as np -import skimage as ski -``` - -:::::::::::::::::::::::::::::::::::::::: callout - -## Import statements in Python - -In Python, the `import` statement is used to -load additional functionality into a program. -This is necessary when we want our code to do something more specialised, -which cannot easily be achieved with the limited set of basic tools and -data structures available in the default Python environment. - -Additional functionality can be loaded as a single function or object, -a module defining several of these, or a library containing many modules. -You will encounter several different forms of `import` statement. - -```python -import skimage # form 1, load whole skimage library -import skimage.draw # form 2, load skimage.draw module only -from skimage.draw import disk # form 3, load only the disk function -import skimage as ski # form 4, load all of skimage into an object called ski -``` - -:::::::::::::::: spoiler - -## Further explanation - -In the example above, form 1 loads the entire scikit-image library into the -program as an object. -Individual modules of the library are then available within that object, -e.g., to access the `disk` function used in [the drawing episode](04-drawing.md), -you would write `skimage.draw.disk()`. - -Form 2 loads only the `draw` module of `skimage` into the program. -The syntax needed to use the module remains unchanged: -to access the `disk` function, -we would use the same function call as given for form 1. - -Form 3 can be used to import only a specific function/class from a library/module. -Unlike the other forms, when this approach is used, -the imported function or class can be called by its name only, -without prefixing it with the name of the library/module from which it was loaded, -i.e., `disk()` instead of `skimage.draw.disk()` using the example above. -One hazard of this form is that importing like this will overwrite any -object with the same name that was defined/imported earlier in the program, -i.e., the example above would replace any existing object called `disk` -with the `disk` function from `skimage.draw`. - -Finally, the `as` keyword can be used when importing, -to define a name to be used as shorthand for the library/module being imported. -This name is referred to as an alias. Typically, using an alias (such as -`np` for the NumPy library) saves us a little typing. -You may see `as` combined with any of the other first three forms of `import` statements. - -Which form is used often depends on -the size and number of additional tools being loaded into the program. - -::::::::::::::::::::::::: - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -Now that we have our libraries loaded, -we will run a Jupyter Magic Command that will ensure our images display -in our Jupyter document with pixel information that will help us -more efficiently run commands later in the session. - -```python -%matplotlib widget -``` - -With that taken care of, let us display the image we have loaded, using -the `imshow` function from the `matplotlib.pyplot` module. - -```python -fig, ax = plt.subplots() -ax.imshow(eight) -``` - -![](fig/eight.png){alt='Image of 8'} - -You might be thinking, -"That does look vaguely like an eight, -and I see two colours but how can that be only 15 pixels". -The display of the eight you see does use a lot more screen pixels to -display our eight so large, but that does not mean there is information -for all those screen pixels in the file. -All those extra pixels are a consequence of our viewer creating -additional pixels through interpolation. -It could have just displayed it as a tiny image using only 15 screen pixels if -the viewer was designed differently. - -While many image file formats contain descriptive metadata that can be essential, -the bulk of a picture file is just arrays of numeric information that, -when interpreted according to a certain rule set, -become recognizable as an image to us. -Our image of an eight is no exception, -and `imageio.v3` stored that image data in an array of arrays making -a 5 x 3 matrix of 15 pixels. -We can demonstrate that by calling on the shape property of our image variable -and see the matrix by printing our image variable to the screen. - -```python -print(eight.shape) -print(eight) -``` - -```output -(5, 3) -[[0. 0. 0.] - [0. 1. 0.] - [0. 0. 0.] - [0. 1. 0.] - [0. 0. 0.]] -``` - -Thus if we have tools that will allow us to manipulate these arrays of numbers, -we can manipulate the image. -The NumPy library can be particularly useful here, -so let's try that out using NumPy array slicing. -Notice that the default behavior of the `imshow` function appended row and -column numbers that will be helpful to us as we try to address individual or -groups of pixels. -First let's load another copy of our eight, and then make it look like a zero. - -To make it look like a zero, -we need to change the number underlying the centremost pixel to be 1. -With the help of those row and column headers, -at this small scale we can determine the centre pixel is in row labeled 2 and -column labeled 1. -Using array slicing, we can then address and assign a new value to that position. - -```python -zero = iio.imread(uri="data/eight.tif") -zero[2, 1]= 1.0 - -# The following line of code creates a new figure for imshow to use in displaying our output. -fig, ax = plt.subplots() -ax.imshow(zero) -print(zero) -``` - -```output -[[0. 0. 0.] - [0. 1. 0.] - [0. 1. 0.] - [0. 1. 0.] - [0. 0. 0.]] -``` - -![](fig/zero.png){alt='Image of 0'} - -:::::::::::::::::::::::::::::::::::::::: callout - -## Coordinate system - -When we process images, we can access, examine, and / or change -the colour of any pixel we wish. -To do this, we need some convention on how to access pixels -individually; a way to give each one a name, or an address of a sort. - -The most common manner to do this, and the one we will use in our programs, -is to assign a modified Cartesian coordinate system to the image. -The coordinate system we usually see in mathematics has -a horizontal x-axis and a vertical y-axis, like this: - -![](fig/cartesian-coordinates.png){alt='Cartesian coordinate system'} - -The modified coordinate system used for our images will have only positive -coordinates, the origin will be in the upper left corner instead of the -centre, and y coordinate values will get larger as they go down instead of up, -like this: - -![](fig/image-coordinates.png){alt='Image coordinate system'} - -This is called a *left-hand coordinate system*. -If you hold your left hand in front of your face and point your thumb at the floor, -your extended index finger will correspond to the x-axis -while your thumb represents the y-axis. - -![](fig/left-hand-coordinates.png){alt='Left-hand coordinate system'} - -Until you have worked with images for a while, -the most common mistake that you will make with coordinates is to forget -that y coordinates get larger as they go down instead of up -as in a normal Cartesian coordinate system. Consequently, it may be helpful to think -in terms of counting down rows (r) for the y-axis and across columns (c) for the x-axis. This -can be especially helpful in cases where you need to transpose image viewer data -provided in *x,y* format to *y,x* format. Thus, we will use *cx* and *ry* where appropriate -to help bridge these two approaches. - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -::::::::::::::::::::::::::::::::::::::: challenge - -## Changing Pixel Values (5 min) - -Load another copy of eight named five, -and then change the value of pixels so you have what looks like a 5 instead of an 8. -Display the image and print out the matrix as well. - -::::::::::::::: solution - -## Solution - -There are many possible solutions, but one method would be . . . - -```python -five = iio.imread(uri="data/eight.tif") -five[1, 2] = 1.0 -five[3, 0] = 1.0 -fig, ax = plt.subplots() -ax.imshow(five) -print(five) -``` - -```output -[[0. 0. 0.] - [0. 1. 1.] - [0. 0. 0.] - [1. 1. 0.] - [0. 0. 0.]] -``` - -![](fig/five.png){alt='Image of 5'} - -::::::::::::::::::::::::: - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -## More colours - -Up to now, we only had a 2 colour matrix, -but we can have more if we use other numbers or fractions. -One common way is to use the numbers between 0 and 255 to allow for -256 different colours or 256 different levels of grey. -Let's try that out. - -```python -# make a copy of eight -three_colours = iio.imread(uri="data/eight.tif") - -# multiply the whole matrix by 128 -three_colours = three_colours * 128 - -# set the middle row (index 2) to the value of 255., -# so you end up with the values 0., 128., and 255. -three_colours[2, :] = 255. -fig, ax = plt.subplots() -ax.imshow(three_colours) -print(three_colours) -``` - -![](fig/three-colours.png){alt='Image of three colours'} - -We now have 3 colours, but are they the three colours you expected? -They all appear to be on a continuum of dark purple on the low end and -yellow on the high end. -This is a consequence of the default colour map (cmap) in this library. -You can think of a colour map as an association or mapping of numbers -to a specific colour. -However, the goal here is not to have one number for every possible colour, -but rather to have a continuum of colours that demonstrate relative intensity. -In our specific case here for example, -255 or the highest intensity is mapped to yellow, -and 0 or the lowest intensity is mapped to a dark purple. -The best colour map for your data will vary and there are many options built in, -but this default selection was not arbitrary. -A lot of science went into making this the default due to its robustness -when it comes to how the human mind interprets relative colour values, -grey-scale printability, -and colour-blind friendliness -(You can read more about this default colour map in -[a Matplotlib tutorial](https://matplotlib.org/stable/tutorials/colors/colormaps.html) -and [an explanatory article by the authors](https://bids.github.io/colormap/)). -Thus it is a good place to start, -and you should change it only with purpose and forethought. -For now, let's see how you can do that using an alternative map -you have likely seen before where it will be even easier to see it as -a mapped continuum of intensities: greyscale. - -```python -fig, ax = plt.subplots() -ax.imshow(three_colours, cmap="gray") -``` - -![](fig/grayscale.png){alt='Image in greyscale'} - -Above we have exactly the same underlying data matrix, but in greyscale. -Zero maps to black, 255 maps to white, and 128 maps to medium grey. -Here we only have a single channel in the data and utilize a grayscale color map -to represent the luminance, or intensity of the data and correspondingly -this channel is referred to as the luminance channel. - -## Even more colours - -This is all well and good at this scale, -but what happens when we instead have a picture of a natural landscape that -contains millions of colours. -Having a one to one mapping of number to colour like this would be inefficient -and make adjustments and building tools to do so very difficult. -Rather than larger numbers, the solution is to have more numbers in more dimensions. -Storing the numbers in a multi-dimensional matrix where each colour or -property like transparency is associated with its own dimension allows -for individual contributions to a pixel to be adjusted independently. -This ability to manipulate properties of groups of pixels separately will be -key to certain techniques explored in later chapters of this lesson. -To get started let's see an example of how different dimensions of information -combine to produce a set of pixels using a 4 x 4 matrix with 3 dimensions -for the colours red, green, and blue. -Rather than loading it from a file, we will generate this example using NumPy. - -```python -# set the random seed so we all get the same matrix -pseudorandomizer = np.random.RandomState(2021) -# create a 4 × 4 checkerboard of random colours -checkerboard = pseudorandomizer.randint(0, 255, size=(4, 4, 3)) -# restore the default map as you show the image -fig, ax = plt.subplots() -ax.imshow(checkerboard) -# display the arrays -print(checkerboard) -``` - -```output -[[[116 85 57] - [128 109 94] - [214 44 62] - [219 157 21]] - - [[ 93 152 140] - [246 198 102] - [ 70 33 101] - [ 7 1 110]] - - [[225 124 229] - [154 194 176] - [227 63 49] - [144 178 54]] - - [[123 180 93] - [120 5 49] - [166 234 142] - [ 71 85 70]]] -``` - -![](fig/checkerboard.png){alt='Image of checkerboard'} - -Previously we had one number being mapped to one colour or intensity. -Now we are combining the effect of 3 numbers to arrive at a single colour value. -Let's see an example of that using the blue square at the end of the second row, -which has the index [1, 3]. - -```python -# extract all the colour information for the blue square -upper_right_square = checkerboard[1, 3, :] -upper_right_square -``` - -This outputs: array([ 7, 1, 110]) -The integers in order represent Red, Green, and Blue. -Looking at the 3 values and knowing how they map, -can help us understand why it is blue. -If we divide each value by 255, which is the maximum, -we can determine how much it is contributing relative to its maximum potential. -Effectively, the red is at 7/255 or 2.8 percent of its potential, -the green is at 1/255 or 0.4 percent, -and blue is 110/255 or 43.1 percent of its potential. -So when you mix those three intensities of colour, -blue is winning by a wide margin, -but the red and green still contribute to make it a slightly different -shade of blue than 0,0,110 would be on its own. - -These colours mapped to dimensions of the matrix may be referred to as channels. -It may be helpful to display each of these channels independently, -to help us understand what is happening. -We can do that by multiplying our image array representation with -a 1d matrix that has a one for the channel we want to keep and zeros for the rest. - -```python -red_channel = checkerboard * [1, 0, 0] -fig, ax = plt.subplots() -ax.imshow(red_channel) -``` - -![](fig/checkerboard-red-channel.png){alt='Image of red channel'} - -```python -green_channel = checkerboard * [0, 1, 0] -fig, ax = plt.subplots() -ax.imshow(green_channel) -``` - -![](fig/checkerboard-green-channel.png){alt='Image of green channel'} - -```python -blue_channel = checkerboard * [0, 0, 1] -fig, ax = plt.subplots() -ax.imshow(blue_channel) -``` - -![](fig/checkerboard-blue-channel.png){alt='Image of blue channel'} - -If we look at the upper [1, 3] square in all three figures, -we can see each of those colour contributions in action. -Notice that there are several squares in the blue figure that look -even more intensely blue than square [1, 3]. -When all three channels are combined though, -the blue light of those squares is being diluted by the relative strength -of red and green being mixed in with them. - -## 24-bit RGB colour - -This last colour model we used, -known as the *RGB (Red, Green, Blue)* model, is the most common. - -As we saw, the RGB model is an *additive* colour model, which means that the primary -colours are mixed together to form other colours. -Most frequently, the amount of the primary colour added is represented as -an integer in the closed range [0, 255] as seen in the example. -Therefore, there are 256 discrete amounts of each primary colour that can be -added to produce another colour. -The number of discrete amounts of each colour, 256, corresponds to the number of -bits used to hold the colour channel value, which is eight (28\=256). -Since we have three channels with 8 bits for each (8+8+8=24), -this is called 24-bit colour depth. - -Any particular colour in the RGB model can be expressed by a triplet of -integers in [0, 255], representing the red, green, and blue channels, -respectively. -A larger number in a channel means that more of that primary colour is present. - -::::::::::::::::::::::::::::::::::::::: challenge - -## Thinking about RGB colours (5 min) - -Suppose that we represent colours as triples (r, g, b), where each of r, g, -and b is an integer in [0, 255]. -What colours are represented by each of these triples? -(Try to answer these questions without reading further.) - -1. (255, 0, 0) -2. (0, 255, 0) -3. (0, 0, 255) -4. (255, 255, 255) -5. (0, 0, 0) -6. (128, 128, 128) - -::::::::::::::: solution - -## Solution - -1. (255, 0, 0) represents red, because the red channel is maximised, while - the other two channels have the minimum values. -2. (0, 255, 0) represents green. -3. (0, 0, 255) represents blue. -4. (255, 255, 255) is a little harder. When we mix the maximum value of all - three colour channels, we see the colour white. -5. (0, 0, 0) represents the absence of all colour, or black. -6. (128, 128, 128) represents a medium shade of gray. - Note that the 24-bit RGB colour model provides at least 254 shades of gray, - rather than only fifty. - -Note that the RGB colour model may run contrary to your experience, -especially if you have mixed primary colours of paint to create new colours. -In the RGB model, the *lack of* any colour is black, -while the *maximum amount* of each of the primary colours is white. -With physical paint, we might start with a white base, -and then add differing amounts of other paints to produce a darker shade. - -::::::::::::::::::::::::: - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -After completing the previous challenge, -we can look at some further examples of 24-bit RGB colours, in a visual way. -The image in the next challenge shows some colour names, -their 24-bit RGB triplet values, and the colour itself. - -::::::::::::::::::::::::::::::::::::::: challenge - -## RGB colour table (optional, not included in timing) - -![](fig/colour-table.png){alt='RGB colour table'} - -We cannot really provide a complete table. -To see why, answer this question: -How many possible colours can be represented with the 24-bit RGB model? - -::::::::::::::: solution - -## Solution - -There are 24 total bits in an RGB colour of this type, -and each bit can be on or off, -and so there are 224 = 16,777,216 -possible colours with our additive, 24-bit RGB colour model. - -::::::::::::::::::::::::: - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -Although 24-bit colour depth is common, there are other options. -For example, we might have 8-bit colour -(3 bits for red and green, but only 2 for blue, providing 8 × 8 × 4 = 256 colours) -or 16-bit colour -(4 bits for red, green, and blue, plus 4 more for transparency, -providing 16 × 16 × 16 = 4096 colours, with 16 transparency levels each). -There are colour depths with more than eight bits per channel, -but as the human eye can only discern approximately 10 million different colours, -these are not often used. - -If you are using an older or inexpensive laptop screen or LCD monitor to view images, -it may only support 18-bit colour, capable of displaying -64 × 64 × 64 = 262,144 colours. -24-bit colour images will be converted in some manner to 18-bit, -and thus the colour quality you see will not match what is actually in the image. - -We can combine our coordinate system with the 24-bit RGB colour model to gain a -conceptual understanding of the images we will be working with. -An image is a rectangular array of pixels, -each with its own coordinate. -Each pixel in the image is a square point of coloured light, -where the colour is specified by a 24-bit RGB triplet. -Such an image is an example of *raster graphics*. - -## Image formats - -Although the images we will manipulate in our programs are conceptualised as -rectangular arrays of RGB triplets, -they are not necessarily created, stored, or transmitted in that format. -There are several image formats we might encounter, -and we should know the basics of at least of few of them. -Some formats we might encounter, and their file extensions, are shown in this table: - -| Format | Extension | -| :-------------------------------------- | :------------ | -| Device-Independent Bitmap (BMP) | .bmp | -| Joint Photographic Experts Group (JPEG) | .jpg or .jpeg | -| Tagged Image File Format (TIFF) | .tif or .tiff | - -## BMP - -The file format that comes closest to our preceding conceptualisation of images -is the Device-Independent Bitmap, or BMP, file format. -BMP files store raster graphics images as long sequences of binary-encoded numbers -that specify the colour of each pixel in the image. -Since computer files are one-dimensional structures, -the pixel colours are stored one row at a time. -That is, the first row of pixels (those with y-coordinate 0) are stored first, -followed by the second row (those with y-coordinate 1), and so on. -Depending on how it was created, -a BMP image might have 8-bit, 16-bit, or 24-bit colour depth. - -24-bit BMP images have a relatively simple file format, -can be viewed and loaded across a wide variety of operating systems, -and have high quality. -However, BMP images are not *compressed*, -resulting in very large file sizes for any useful image resolutions. - -The idea of image compression is important to us for two reasons: -first, compressed images have smaller file sizes, -and are therefore easier to store and transmit; -and second, -compressed images may not have as much detail as their uncompressed counterparts, -and so our programs may not be able to detect some important aspect -if we are working with compressed images. -Since compression is important to us, -we should take a brief detour and discuss the concept. - -## Image compression - -Before discussing additional formats, -familiarity with image compression will be helpful. -Let's delve into that subject with a challenge. -For this challenge, -you will need to know about bits / bytes and -how those are used to express computer storage capacities. -If you already know, you can skip to the challenge below. - -:::::::::::::::::::::::::::::::::::::::: callout - -## Bits and bytes - -Before we talk specifically about images, -we first need to understand how numbers are stored in a modern digital computer. -When we think of a number, -we do so using a *decimal*, or *base-10* place-value number system. -For example, a number like 659 is -6 × 102 + 5 × 101 + 9 × 100. -Each digit in the number is multiplied by a power of 10, -based on where it occurs, -and there are 10 digits that can occur in each position -(0, 1, 2, 3, 4, 5, 6, 7, 8, 9). - -In principle, -computers could be constructed to represent numbers in exactly the same way. -But, the electronic circuits inside a computer are much easier to construct -if we restrict the numeric base to only two, instead of 10. -(It is easier for circuitry to tell the difference between -two voltage levels than it is to differentiate among 10 levels.) -So, values in a computer are stored using a *binary*, -or *base-2* place-value number system. - -In this system, each symbol in a number is called a *bit* instead of a digit, -and there are only two values for each bit (0 and 1). -We might imagine a four-bit binary number, 1101. -Using the same kind of place-value expansion as we did above for 659, -we see that -1101 = 1 × 23 + 1 × 22 + 0 × 21 + 1 × 20, -which if we do the math is 8 + 4 + 0 + 1, or 13 in decimal. - -Internally, -computers have a minimum number of bits that they work with at a given time: eight. -A group of eight bits is called a *byte*. -The amount of memory (RAM) and drive space our computers have is quantified -by terms like Megabytes (MB), Gigabytes (GB), and Terabytes (TB). -The following table provides more formal definitions for these terms. - -| Unit | Abbreviation | Size | -| :-------------------------------------- | ------------- | :--------- | -| Kilobyte | KB | 1024 bytes | -| Megabyte | MB | 1024 KB | -| Gigabyte | GB | 1024 MB | -| Terabyte | TB | 1024 GB | - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -::::::::::::::::::::::::::::::::::::::: challenge - -## BMP image size (optional, not included in timing) - -Imagine that we have a fairly large, but very boring image: -a 5,000 × 5,000 pixel image composed of nothing but white pixels. -If we used an uncompressed image format such as BMP, -with the 24-bit RGB colour model, -how much storage would be required for the file? - -::::::::::::::: solution - -## Solution - -In such an image, there are 5,000 × 5,000 = 25,000,000 pixels, -and 24 bits for each pixel, -leading to 25,000,000 × 24 = 600,000,000 bits, -or 75,000,000 bytes (71.5MB). -That is quite a lot of space for a very uninteresting image! - -::::::::::::::::::::::::: - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -Since image files can be very large, -various *compression* schemes exist for saving -(approximately) the same information while using less space. -These compression techniques can be categorised as *lossless* or *lossy*. - -### Lossless compression - -In lossless image compression, -we apply some algorithm (i.e., a computerised procedure) to the image, -resulting in a file that is significantly smaller than -the uncompressed BMP file equivalent would be. -Then, when we wish to load and view or process the image, -our program reads the compressed file, and reverses the compression process, -resulting in an image that is *identical* to the original. -Nothing is lost in the process -- hence the term "lossless." - -The general idea of lossless compression is to somehow detect -long patterns of bytes in a file that are repeated over and over, -and then assign a smaller bit pattern to represent the longer sample. -Then, the compressed file is made up of the smaller patterns, -rather than the larger ones, -thus reducing the number of bytes required to save the file. -The compressed file also contains -a table of the substituted patterns and the originals, -so when the file is decompressed it can be -made identical to the original before compression. - -To provide you with a concrete example, -consider the 71.5 MB white BMP image discussed above. -When put through the zip compression utility on Microsoft Windows, -the resulting .zip file is only 72 KB in size! -That is, the .zip version of the image is -three orders of magnitude smaller than the original, -and it can be decompressed into a file that is -byte-for-byte the same as the original. -Since the original is so repetitious - -simply the same colour triplet repeated 25,000,000 times - -the compression algorithm can dramatically reduce the size of the file. - -If you work with .zip or .gz archives, you are dealing with lossless -compression. - -### Lossy compression - -Lossy compression takes the original image and discards some of the detail in it, -resulting in a smaller file format. -The goal is to only throw away detail that someone viewing the image would not notice. -Many lossy compression schemes have adjustable levels of compression, -so that the image creator can choose the amount of detail that is lost. -The more detail that is sacrificed, -the smaller the image files will be - -but of course, the detail and richness of the image will be lower as well. - -This is probably fine for images that are shown on Web pages -or printed off on 4 × 6 photo paper, -but may or may not be fine for scientific work. -You will have to decide whether the loss of image quality and detail are -important to your work, -versus the space savings afforded by a lossy compression format. - -It is important to understand that -once an image is saved in a lossy compression format, -the lost detail is just that - lost. -I.e., unlike lossless formats, -given an image saved in a lossy format, -there is no way to reconstruct the original image in a byte-by-byte manner. - -## JPEG - -JPEG images are perhaps the most commonly encountered digital images today. -JPEG uses lossy compression, -and the degree of compression can be tuned to your liking. -It supports 24-bit colour depth, -and since the format is so widely used, -JPEG images can be viewed and manipulated easily on all computing platforms. - -::::::::::::::::::::::::::::::::::::::: challenge - -## Examining actual image sizes (optional, not included in timing) - -Let us see the effects of image compression on image size with actual images. -The following script creates a square white image 5000 x 5000 pixels, -and then saves it as a BMP and as a JPEG image. - -```python -dim = 5000 - -img = np.zeros((dim, dim, 3), dtype="uint8") -img.fill(255) - -iio.imwrite(uri="data/ws.bmp", image=img) -iio.imwrite(uri="data/ws.jpg", image=img) -``` - -Examine the file sizes of the two output files, `ws.bmp` and `ws.jpg`. -Does the BMP image size match our previous prediction? -How about the JPEG? - -::::::::::::::: solution - -## Solution - -The BMP file, `ws.bmp`, is 75,000,054 bytes, -which matches our prediction very nicely. -The JPEG file, `ws.jpg`, is 392,503 bytes, -two orders of magnitude smaller than the bitmap version. - -::::::::::::::::::::::::: - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -::::::::::::::::::::::::::::::::::::::: challenge - -## Comparing lossless versus lossy compression (optional, not included in timing) - -Let us see a hands-on example of lossless versus lossy compression. -Open a terminal (or Windows PowerShell) and navigate to the `data/` directory. -The two output images, `ws.bmp` and `ws.jpg`, should still be in the directory, -along with another image, `tree.jpg`. - -We can apply lossless compression to any file by using the `zip` command. -Recall that the `ws.bmp` file contains 75,000,054 bytes. -Apply lossless compression to this image by executing the following command: -`zip ws.zip ws.bmp` -(`Compress-Archive ws.bmp ws.zip` with PowerShell). -This command tells the computer to create a new compressed file, -`ws.zip`, from the original bitmap image. -Execute a similar command on the tree JPEG file: `zip tree.zip tree.jpg` -(`Compress-Archive tree.jpg tree.zip` with PowerShell). - -Having created the compressed file, -use the `ls -l` command (`dir` with PowerShell) to display the contents of the directory. -How big are the compressed files? -How do those compare to the size of `ws.bmp` and `tree.jpg`? -What can you conclude from the relative sizes? - -::::::::::::::: solution - -## Solution - -Here is a partial directory listing, showing the sizes of the relevant files there: - -```output --rw-rw-r-- 1 diva diva 154344 Jun 18 08:32 tree.jpg --rw-rw-r-- 1 diva diva 146049 Jun 18 08:53 tree.zip --rw-rw-r-- 1 diva diva 75000054 Jun 18 08:51 ws.bmp --rw-rw-r-- 1 diva diva 72986 Jun 18 08:53 ws.zip -``` - -We can see that the regularity of the bitmap image -(remember, it is a 5,000 x 5,000 pixel image containing only white pixels) -allows the lossless compression scheme to compress the file quite effectively. -On the other hand, compressing `tree.jpg` does not create a much smaller file; -this is because the JPEG image was already in a compressed format. - -::::::::::::::::::::::::: - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -Here is an example showing how JPEG compression might impact image quality. -Consider this image of several maize seedlings -(scaled down here from 11,339 × 11,336 pixels in order to fit the display). - -![](fig/quality-original.jpg){alt='Original image'} - -Now, let us zoom in and look at a small section of the label in the original, -first in the uncompressed format: - -![](fig/quality-tif.jpg){alt='Enlarged, uncompressed'} - -Here is the same area of the image, but in JPEG format. -We used a fairly aggressive compression parameter to make the JPEG, -in order to illustrate the problems you might encounter with the format. - -![](fig/quality-jpg.jpg){alt='Enlarged, compressed'} - -The JPEG image is of clearly inferior quality. -It has less colour variation and noticeable pixelation. -Quality differences become even more marked when one examines -the colour histograms for each image. -A histogram shows how often each colour value appears in an image. -The histograms for the uncompressed (left) and compressed (right) images -are shown below: - -![](fig/quality-histogram.jpg){alt='Uncompressed histogram'} - -We learn how to make histograms such as these later on in the workshop. -The differences in the colour histograms are even more apparent than in the -images themselves; -clearly the colours in the JPEG image are different from the uncompressed version. - -If the quality settings for your JPEG images are high -(and the compression rate therefore relatively low), -the images may be of sufficient quality for your work. -It all depends on how much quality you need, -and what restrictions you have on image storage space. -Another consideration may be *where* the images are stored. -For example, if your images are stored in the cloud and therefore -must be downloaded to your system before you use them, -you may wish to use a compressed image format to speed up file transfer time. - -## PNG - -PNG images are well suited for storing diagrams. It uses a lossless compression and is hence often used -in web applications for non-photographic images. The format is able to store RGB and plain luminance (single channel, without an associated color) data, among others. Image data is stored row-wise and then, per row, a simple filter, like taking the difference of adjacent pixels, can be applied to -increase the compressability of the data. The filtered data is then compressed in the next step and written out to the disk. - -## TIFF - -TIFF images are popular with publishers, graphics designers, and photographers. -TIFF images can be uncompressed, -or compressed using either lossless or lossy compression schemes, -depending on the settings used, -and so TIFF images seem to have the benefits of both the BMP and JPEG formats. -The main disadvantage of TIFF images -(other than the size of images in the uncompressed version of the format) -is that they are not universally readable by image viewing and manipulation software. - -## Metadata - -JPEG and TIFF images support the inclusion of *metadata* in images. -Metadata is textual information that is contained within an image file. -Metadata holds information about the image itself, -such as when the image was captured, -where it was captured, -what type of camera was used and with what settings, etc. -We normally don't see this metadata when we view an image, -but we can view it independently if we wish to -(see [*Accessing Metadata*](#accessing-metadata), below). -The important thing to be aware of at this stage is that -you cannot rely on the metadata of an image being fully preserved -when you use software to process that image. -The image reader/writer library that we use throughout this lesson, -`imageio.v3`, includes metadata when saving new images but may fail to keep -certain metadata fields. -In any case, remember: **if metadata is important to you, -take precautions to always preserve the original files**. - -:::::::::::::::::::::::::::::::::::::::: callout - -## Accessing Metadata - -`imageio.v3` provides a way to display or explore the metadata -associated with an image. Metadata is served independently from pixel data: - -```python -# read metadata -metadata = iio.immeta(uri="data/eight.tif") -# display the format-specific metadata -metadata -``` - -```output -{'is_fluoview': False, - 'is_nih': False, - 'is_micromanager': False, - 'is_ome': False, - 'is_lsm': False, - 'is_reduced': False, - 'is_shaped': True, - 'is_stk': False, - 'is_tiled': False, - 'is_mdgel': False, - 'compression': , - 'predictor': 1, - 'is_mediacy': False, - 'description': '{"shape": [5, 3]}', - 'description1': '', - 'is_imagej': False, - 'software': 'tifffile.py', - 'resolution_unit': 1, - 'resolution': (1.0, 1.0, 'NONE')} -``` - -Many popular image editing programs have built-in metadata viewing -capabilities. A platform-independent open-source tool that allows -users to read, write, and edit metadata is -[ExifTool](https://exiftool.org/). It can handle a wide range of file -types and metadata formats but requires some technical knowledge to be -used effectively. -Other software exists that can help you handle metadata, -e.g., [Fiji](https://imagej.net/Fiji) -and [ImageMagick](https://imagemagick.org/index.php). -You may want to explore these options if you need to work with -the metadata of your images. - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -## Summary of image formats used in this lesson - -The following table summarises the characteristics of the BMP, JPEG, and TIFF -image formats: - -| Format | Compression | Metadata | Advantages | Disadvantages | -| :--------------- | :------------ | :--------- | :--------------------- | :--------------------- | -| BMP | None | None | Universally viewable, high quality | Large file sizes | -| JPEG | Lossy | Yes | Universally viewable, smaller file size | Detail may be lost | -| PNG | Lossless | [Yes](https://www.w3.org/TR/PNG/#11keywords) | Universally viewable, [open standard](https://www.w3.org/TR/PNG/), smaller file size | Metadata less flexible than TIFF, RGB only | -| TIFF | None, lossy, or lossless | Yes | High quality or smaller file size | Not universally viewable | - -:::::::::::::::::::::::::::::::::::::::: keypoints - -- Digital images are represented as rectangular arrays of square pixels. -- Digital images use a left-hand coordinate system, with the origin in the upper left corner, the x-axis running to the right, and the y-axis running down. Some learners may prefer to think in terms of counting down rows for the y-axis and across columns for the x-axis. Thus, we will make an effort to allow for both approaches in our lesson presentation. -- Most frequently, digital images use an additive RGB model, with eight bits for the red, green, and blue channels. -- scikit-image images are stored as multi-dimensional NumPy arrays. -- In scikit-image images, the red channel is specified first, then the green, then the blue, i.e., RGB. -- Lossless compression retains all the details in an image, but lossy compression results in loss of some of the original image detail. -- BMP images are uncompressed, meaning they have high quality but also that their file sizes are large. -- JPEG images use lossy compression, meaning that their file sizes are smaller, but image quality may suffer. -- TIFF images can be uncompressed or compressed with lossy or lossless compression. -- Depending on the camera or sensor, various useful pieces of information may be stored in an image file, in the image metadata. - -:::::::::::::::::::::::::::::::::::::::::::::::::: diff --git a/episodes/03-skimage-images.md b/episodes/03-skimage-images.md deleted file mode 100644 index 34842802d..000000000 --- a/episodes/03-skimage-images.md +++ /dev/null @@ -1,589 +0,0 @@ ---- -title: Working with scikit-image -teaching: 70 -exercises: 50 ---- - -::::::::::::::::::::::::::::::::::::::: objectives - -- Read and save images with imageio. -- Display images with Matplotlib. -- Resize images with scikit-image. -- Perform simple image thresholding with NumPy array operations. -- Extract sub-images using array slicing. - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -:::::::::::::::::::::::::::::::::::::::: questions - -- How can the scikit-image Python computer vision library be used to work with images? - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -We have covered much of how images are represented in computer software. In this episode we will learn some more methods -for accessing and changing digital images. - -## First, import the packages needed for this episode - -```python -import imageio.v3 as iio -import ipympl -import matplotlib.pyplot as plt -import numpy as np -import skimage as ski - -%matplotlib widget -``` - -## Reading, displaying, and saving images - -Imageio provides intuitive functions for reading and writing (saving) images. -All of the popular image formats, such as BMP, PNG, JPEG, and TIFF are supported, -along with several more esoteric formats. Check the -[Supported Formats docs](https://imageio.readthedocs.io/en/stable/formats/index.html) -for a list of all formats. -Matplotlib provides a large collection of plotting utilities. - -Let us examine a simple Python program to load, display, -and save an image to a different format. -Here are the first few lines: - -```python -"""Python program to open, display, and save an image.""" -# read image -chair = iio.imread(uri="data/chair.jpg") -``` - -We use the `iio.imread()` function to read a JPEG image entitled **chair.jpg**. -Imageio reads the image, converts it from JPEG into a NumPy array, -and returns the array; we save the array in a variable named `chair`. - -Next, we will do something with the image: - -```python -fig, ax = plt.subplots() -ax.imshow(chair) -``` - -Once we have the image in the program, -we first call `fig, ax = plt.subplots()` so that we will have -a fresh figure with a set of axes independent from our previous calls. -Next we call `ax.imshow()` in order to display the image. - -Now, we will save the image in another format: - -```python -# save a new version in .tif format -iio.imwrite(uri="data/chair.tif", image=chair) -``` - -The final statement in the program, `iio.imwrite(uri="data/chair.tif", image=chair)`, -writes the image to a file named `chair.tif` in the `data/` directory. -The `imwrite()` function automatically determines the type of the file, -based on the file extension we provide. -In this case, the `.tif` extension causes the image to be saved as a TIFF. - -:::::::::::::::::::::::::::::::::::::::: callout - -## Metadata, revisited - -Remember, as mentioned in the previous section, *images saved with `imwrite()` -will not retain all metadata associated with the original image -that was loaded into Python!* -If the image metadata is important to you, be sure to **always keep an unchanged -copy of the original image!** - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -::::::::::::::::::::::::::::::::::::::::: callout - -## Extensions do not always dictate file type - -The `iio.imwrite()` function automatically uses the file type we specify in -the file name parameter's extension. -Note that this is not always the case. -For example, if we are editing a document in Microsoft Word, -and we save the document as `paper.pdf` instead of `paper.docx`, -the file *is not* saved as a PDF document. - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -::::::::::::::::::::::::::::::::::::::::: callout - -## Named versus positional arguments - -When we call functions in Python, -there are two ways we can specify the necessary arguments. -We can specify the arguments *positionally*, i.e., -in the order the parameters appear in the function definition, -or we can use *named arguments*. - -For example, the `iio.imwrite()` -[function definition](https://imageio.readthedocs.io/en/stable/_autosummary/imageio.v3.imwrite.html) -specifies two parameters, -the resource to save the image to (e.g., a file name, an http address) and -the image to write to disk. -So, we could save the chair image in the sample code above -using positional arguments like this: - -`iio.imwrite("data/chair.tif", image)` - -Since the function expects the first argument to be the file name, -there is no confusion about what `"data/chair.jpg"` means. The same goes -for the second argument. - -The style we will use in this workshop is to name each argument, like this: - -`iio.imwrite(uri="data/chair.tif", image=image)` - -This style will make it easier for you to learn how to use the variety of -functions we will cover in this workshop. - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -::::::::::::::::::::::::::::::::::::::: challenge - -## Resizing an image (10 min) - -Using the `chair.jpg` image located in the data folder, -write a Python script to read your image into a variable named `chair`. -Then, resize the image to 10 percent of its current size using these lines of code: - -```python -new_shape = (chair.shape[0] // 10, chair.shape[1] // 10, chair.shape[2]) -resized_chair = ski.transform.resize(image=chair, output_shape=new_shape) -resized_chair = ski.util.img_as_ubyte(resized_chair) -``` - -As it is used here, -the parameters to the `ski.transform.resize()` function are -the image to transform, `chair`, -the dimensions we want the new image to have, `new_shape`. - -::::::::::::::::::::::::::::::::::::::::: callout - -Note that the pixel values in the new image are an approximation of -the original values and should not be confused with actual, observed -data. This is because scikit-image interpolates the pixel values when -reducing or increasing the size of an -image. `ski.transform.resize` has a number of optional -parameters that allow the user to control this interpolation. You -can find more details in the [scikit-image -documentation](https://scikit-image.org/docs/stable/api/skimage.transform.html#skimage.transform.resize). - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -Image files on disk are normally stored as whole numbers for space efficiency, -but transformations and other math operations often result in -conversion to floating point numbers. -Using the `ski.util.img_as_ubyte()` method converts it back to whole numbers -before we save it back to disk. -If we don't convert it before saving, -`iio.imwrite()` may not recognise it as image data. - -Next, write the resized image out to a new file named `resized.jpg` -in your data directory. -Finally, use `ax.imshow()` with each of your image variables to display -both images in your notebook. -Don't forget to use `fig, ax = plt.subplots()` so you don't overwrite -the first image with the second. -Images may appear the same size in jupyter, -but you can see the size difference by comparing the scales for each. -You can also see the difference in file storage size on disk by -hovering your mouse cursor over the original -and the new files in the Jupyter file browser, using `ls -l` in your shell -(`dir` with Windows PowerShell), or viewing file sizes in the OS file browser if it is configured so. - -::::::::::::::: solution - -## Solution - -Here is what your Python script might look like. - -```python -"""Python script to read an image, resize it, and save it under a different name.""" - -# read in image -chair = iio.imread(uri="data/chair.jpg") - -# resize the image -new_shape = (chair.shape[0] // 10, chair.shape[1] // 10, chair.shape[2]) -resized_chair = ski.transform.resize(image=chair, output_shape=new_shape) -resized_chair = ski.util.img_as_ubyte(resized_chair) - -# write out image -iio.imwrite(uri="data/resized_chair.jpg", image=resized_chair) - -# display images -fig, ax = plt.subplots() -ax.imshow(chair) -fig, ax = plt.subplots() -ax.imshow(resized_chair) -``` - -The script resizes the `data/chair.jpg` image by a factor of 10 in both dimensions, -saves the result to the `data/resized_chair.jpg` file, -and displays original and resized for comparision. - -::::::::::::::::::::::::: - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -## Manipulating pixels - -In [the *Image Basics* episode](02-image-basics.md), -we individually manipulated the colours of pixels by changing the numbers stored -in the image's NumPy array. Let's apply the principles learned there -along with some new principles to a real world example. - -Suppose we are interested in this maize root cluster image. -We want to be able to focus our program's attention on the roots themselves, -while ignoring the black background. - -![](fig/maize-root-cluster.jpg){alt='Root cluster image'} - -Since the image is stored as an array of numbers, -we can simply look through the array for pixel colour values that are -less than some threshold value. -This process is called *thresholding*, -and we will see more powerful methods to perform the thresholding task in -[the *Thresholding* episode](07-thresholding.md). -Here, though, we will look at a simple and elegant NumPy method for thresholding. -Let us develop a program that keeps only the pixel colour values in an image -that have value greater than or equal to 128. -This will keep the pixels that are brighter than half of "full brightness", -i.e., pixels that do not belong to the black background. - -We will start by reading the image and displaying it. - -::::::::::::::::::::::::::::::::::::::::: callout - -## Loading images with imageio: Read-only arrays - -When loading an image with imageio, in certain situations the image is stored in a read-only array. If you attempt to manipulate the pixels in a read-only array, you will receive an error message `ValueError: assignment destination is read-only`. In order to make the image array writeable, we can create a copy with `image = np.array(image)` before manipulating the pixel values. - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -```python -"""Python script to ignore low intensity pixels in an image.""" - -# read input image -maize_roots = iio.imread(uri="data/maize-root-cluster.jpg") -maize_roots = np.array(maize_roots) - -# display original image -fig, ax = plt.subplots() -ax.imshow(maize_roots) -``` - -Now we can threshold the image and display the result. - -```python -# keep only high-intensity pixels -maize_roots[maize_roots < 128] = 0 - -# display modified image -fig, ax = plt.subplots() -ax.imshow(maize_roots) -``` - -The NumPy command to ignore all low-intensity pixels is `roots[roots < 128] = 0`. -Every pixel colour value in the whole 3-dimensional array with a value less -that 128 is set to zero. -In this case, -the result is an image in which the extraneous background detail has been removed. - -![](fig/maize-root-cluster-threshold.jpg){alt='Thresholded root image'} - -## Converting colour images to grayscale - -It is often easier to work with grayscale images, which have a single channel, -instead of colour images, which have three channels. -scikit-image offers the function `ski.color.rgb2gray()` to achieve this. -This function adds up the three colour channels in a way that matches -human colour perception, -see [the scikit-image documentation for details](https://scikit-image.org/docs/dev/api/skimage.color.html#skimage.color.rgb2gray). -It returns a grayscale image with floating point values in the range from 0 to 1. -We can use the function `ski.util.img_as_ubyte()` in order to convert it back to the -original data type and the data range back 0 to 255. -Note that it is often better to use image values represented by floating point values, -because using floating point numbers is numerically more stable. - -:::::::::::::::::::::::::::::::::::::::: callout - -## Colour and `color` - -The Carpentries generally prefers UK English spelling, -which is why we use "colour" in the explanatory text of this lesson. -However, scikit-image contains many modules and functions that include -the US English spelling, `color`. -The exact spelling matters here, -e.g. you will encounter an error if you try to run `ski.colour.rgb2gray()`. -To account for this, we will use the US English spelling, `color`, -in example Python code throughout the lesson. -You will encounter a similar approach with "centre" and `center`. - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -```python -"""Python script to load a color image as grayscale.""" - -# read input image -chair = iio.imread(uri="data/chair.jpg") - -# display original image -fig, ax = plt.subplots() -ax.imshow(chair) - -# convert to grayscale and display -gray_chair = ski.color.rgb2gray(chair) -fig, ax = plt.subplots() -ax.imshow(gray_chair, cmap="gray") -``` - -We can also load colour images as grayscale directly by -passing the argument `mode="L"` to `iio.imread()`. - -```python -"""Python script to load a color image as grayscale.""" - -# read input image, based on filename parameter -gray_chair = iio.imread(uri="data/chair.jpg", mode="L") - -# display grayscale image -fig, ax = plt.subplots() -ax.imshow(gray_chair, cmap="gray") -``` - -The first argument to `iio.imread()` is the filename of the image. -The second argument `mode="L"` determines the type and range of the pixel values in the image (e.g., an 8-bit pixel has a range of 0-255). This argument is forwarded to the `pillow` backend, a Python imaging library for which mode "L" means 8-bit pixels and single-channel (i.e., grayscale). The backend used by `iio.imread()` may be specified as an optional argument: to use `pillow`, you would -pass `plugin="pillow"`. If the backend is not specified explicitly, `iio.imread()` determines the backend to use based on the image type. - -::::::::::::::::::::::::::::::::::::::::: callout - -## Loading images with imageio: Pixel type and depth - -When loading an image with `mode="L"`, the pixel values are stored as 8-bit integer numbers that can take values in the range 0-255. However, pixel values may also be stored with other types and ranges. For example, some scikit-image functions return the pixel values as floating point numbers in the range 0-1. The type and range of the pixel values are important for the colorscale when plotting, and for masking and thresholding images as we will see later in the lesson. If you are unsure about the type of the pixel values, you can inspect it with `print(image.dtype)`. For the example above, you should find that it is `dtype('uint8')` indicating 8-bit integer numbers. - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -::::::::::::::::::::::::::::::::::::::: challenge - -## Keeping only low intensity pixels (10 min) - -A little earlier, we showed how we could use Python and scikit-image to turn -on only the high intensity pixels from an image, while turning all the low -intensity pixels off. -Now, you can practice doing the opposite - keeping all -the low intensity pixels while changing the high intensity ones. - -The file `data/sudoku.png` is an RGB image of a sudoku puzzle: - -![](fig/sudoku.png){alt='Su-Do-Ku puzzle'} - -Your task is to load the image in grayscale format and turn all of -the bright pixels in the image to a -light gray colour. In other words, mask the bright pixels that have -a pixel value greater than, say, 192 and set their value to 192 (the -value 192 is chosen here because it corresponds to 75% of the -range 0-255 of an 8-bit pixel). The results should look like this: - -![](fig/sudoku-gray.png){alt='Modified Su-Do-Ku puzzle'} - -*Hint: the `cmap`, `vmin`, and `vmax` parameters of `matplotlib.pyplot.imshow` -will be needed to display the modified image as desired. See the [Matplotlib -documentation](https://matplotlib.org/stable/api/_as_gen/matplotlib.pyplot.imshow.html) -for more details on `cmap`, `vmin`, and `vmax`.* - -::::::::::::::: solution - -## Solution - -First, load the image file `data/sudoku.png` as a grayscale image. -Note we may want to create a copy of the image array to avoid modifying our original variable and -also because `imageio.v3.imread` sometimes returns a non-writeable image. - -```python -sudoku = iio.imread(uri="data/sudoku.png", mode="L") -sudoku_gray_background = np.array(sudoku) -``` - -Then change all bright pixel values greater than 192 to 192: - -```python -sudoku_gray_background[sudoku_gray_background > 192] = 192 -``` - -Finally, display the original and modified images side by side. Note that we have to specify `vmin=0` and `vmax=255` as the range of the colorscale because it would otherwise automatically adjust to the new range 0-192. - -```python -fig, ax = plt.subplots(ncols=2) -ax[0].imshow(sudoku, cmap="gray", vmin=0, vmax=255) -ax[1].imshow(sudoku_gray_background, cmap="gray", vmin=0, vmax=255) -``` - -::::::::::::::::::::::::: - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -:::::::::::::::::::::::::::::::::::::::: callout - -## Plotting single channel images (cmap, vmin, vmax) - -Compared to a colour image, a grayscale image contains only a single -intensity value per pixel. When we plot such an image with `ax.imshow`, -Matplotlib uses a colour map, to assign each intensity value a colour. -The default colour map is called "viridis" and maps low values to purple -and high values to yellow. We can instruct Matplotlib to map low values -to black and high values to white instead, by calling `ax.imshow` with -`cmap="gray"`. -[The documentation contains an overview of pre-defined colour maps](https://matplotlib.org/stable/gallery/color/colormap_reference.html). - -Furthermore, Matplotlib determines the minimum and maximum values of -the colour map dynamically from the image, by default. That means that in -an image where the minimum is 64 and the maximum is 192, those values -will be mapped to black and white respectively (and not dark gray and light -gray as you might expect). If there are defined minimum and maximum vales, -you can specify them via `vmin` and `vmax` to get the desired output. - -If you forget about this, it can lead to unexpected results. Try removing -the `vmax` parameter from the sudoku challenge solution and see what happens. - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -## Access via slicing - -As noted in the previous lesson scikit-image images are stored as NumPy arrays, -so we can use array slicing to select rectangular areas of an image. -Then, we can save the selection as a new image, change the pixels in the image, -and so on. -It is important to -remember that coordinates are specified in *(ry, cx)* order and that colour values -are specified in *(r, g, b)* order when doing these manipulations. - -Consider this image of a whiteboard, and suppose that we want to create a -sub-image with just the portion that says "odd + even = odd," along with the -red box that is drawn around the words. - -![](fig/board.jpg){alt='Whiteboard image'} - -Using `matplotlib.pyplot.imshow` -we can determine the coordinates of the corners of the area we wish to extract -by hovering the mouse near the points of interest and noting the coordinates -(remember to run `%matplotlib widget` first if you haven't already). -If we do that, we might settle on a rectangular -area with an upper-left coordinate of *(135, 60)* -and a lower-right coordinate of *(480, 150)*, -as shown in this version of the whiteboard picture: - -![](fig/board-coordinates.jpg){alt='Whiteboard coordinates'} - -Note that the coordinates in the preceding image are specified in *(cx, ry)* order. -Now if our entire whiteboard image is stored as a NumPy array named `image`, -we can create a new image of the selected region with a statement like this: - -`clip = image[60:151, 135:481, :]` - -Our array slicing specifies the range of y-coordinates or rows first, `60:151`, -and then the range of x-coordinates or columns, `135:481`. -Note we go one beyond the maximum value in each dimension, -so that the entire desired area is selected. -The third part of the slice, `:`, -indicates that we want all three colour channels in our new image. - -A script to create the subimage would start by loading the image: - -```python -"""Python script demonstrating image modification and creation via NumPy array slicing.""" - -# load and display original image -board = iio.imread(uri="data/board.jpg") -board = np.array(board) -fig, ax = plt.subplots() -ax.imshow(board) -``` - -Then we use array slicing to -create a new image with our selected area and then display the new image. - -```python -# extract, display, and save sub-image -clipped_board = board[60:151, 135:481, :] -fig, ax = plt.subplots() -ax.imshow(clipped_board) -iio.imwrite(uri="data/clipped_board.tif", image=clipped_board) -``` - -We can also change the values in an image, as shown next. - -```python -# replace clipped area with sampled color -color = board[330, 90] -board[60:151, 135:481] = color -fig, ax = plt.subplots() -ax.imshow(board) -``` - -First, we sample a single pixel's colour at a particular location of the -image, saving it in a variable named `color`, -which creates a 1 × 1 × 3 NumPy array with the blue, green, and red colour values -for the pixel located at *(ry = 330, cx = 90)*. -Then, with the `img[60:151, 135:481] = color` command, -we modify the image in the specified area. -From a NumPy perspective, -this changes all the pixel values within that range to array saved in -the `color` variable. -In this case, the command "erases" that area of the whiteboard, -replacing the words with a beige colour, -as shown in the final image produced by the program: - -![](fig/board-final.jpg){alt='"Erased" whiteboard'} - -::::::::::::::::::::::::::::::::::::::: challenge - -## Practicing with slices (10 min - optional, not included in timing) - -Using the techniques you just learned, write a script that -creates, displays, and saves a sub-image containing -only the plant and its roots from "data/maize-root-cluster.jpg" - -::::::::::::::: solution - -## Solution - -Here is the completed Python program to select only the plant and roots -in the image. - -```python -"""Python script to extract a sub-image containing only the plant and roots in an existing image.""" - -# load and display original image -maize_roots = iio.imread(uri="data/maize-root-cluster.jpg") -fig, ax = plt.subplots() -ax.imshow(maize_roots) - -# extract and display sub-image -clipped_maize = maize_roots[0:400, 275:550, :] -fig, ax = plt.subplots() -ax.imshow(clipped_maize) - - -# save sub-image -iio.imwrite(uri="data/clipped_maize.jpg", image=clipped_maize) -``` - -::::::::::::::::::::::::: - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -:::::::::::::::::::::::::::::::::::::::: keypoints - -- Images are read from disk with the `iio.imread()` function. -- We create a window that automatically scales the displayed image with Matplotlib and calling `imshow()` on the global figure object. -- Colour images can be transformed to grayscale using `ski.color.rgb2gray()` or, in many cases, be read as grayscale directly by passing the argument `mode="L"` to `iio.imread()`. -- We can resize images with the `ski.transform.resize()` function. -- NumPy array commands, such as `image[image < 128] = 0`, can be used to manipulate the pixels of an image. -- Array slicing can be used to extract sub-images or modify areas of images, e.g., `clip = image[60:150, 135:480, :]`. -- Metadata is not retained when images are loaded as NumPy arrays using `iio.imread()`. - -:::::::::::::::::::::::::::::::::::::::::::::::::: diff --git a/episodes/04-drawing.md b/episodes/04-drawing.md deleted file mode 100644 index ae1c4446b..000000000 --- a/episodes/04-drawing.md +++ /dev/null @@ -1,584 +0,0 @@ ---- -title: Drawing and Bitwise Operations -teaching: 45 -exercises: 45 ---- - -::::::::::::::::::::::::::::::::::::::: objectives - -- Create a blank, black scikit-image image. -- Draw rectangles and other shapes on scikit-image images. -- Explain how a white shape on a black background can be used as a mask to select specific parts of an image. -- Use bitwise operations to apply a mask to an image. - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -:::::::::::::::::::::::::::::::::::::::: questions - -- How can we draw on scikit-image images and use bitwise operations and masks to select certain parts of an image? - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -The next series of episodes covers a basic toolkit of scikit-image operators. -With these tools, -we will be able to create programs to perform simple analyses of images -based on changes in colour or shape. - -## First, import the packages needed for this episode - -```python -import imageio.v3 as iio -import ipympl -import matplotlib.pyplot as plt -import numpy as np -import skimage as ski - -%matplotlib widget -``` - -Here, we import the same packages as earlier in the lesson. - -## Drawing on images - -Often we wish to select only a portion of an image to analyze, -and ignore the rest. -Creating a rectangular sub-image with slicing, -as we did in [the *Working with scikit-image* episode](03-skimage-images.md) -is one option for simple cases. -Another option is to create another special image, -of the same size as the original, -with white pixels indicating the region to save and black pixels everywhere else. -Such an image is called a *mask*. -In preparing a mask, we sometimes need to be able to draw a shape - -a circle or a rectangle, say - -on a black image. -scikit-image provides tools to do that. - -Consider this image of maize seedlings: - -![](fig/maize-seedlings.jpg){alt='Maize seedlings'} - -Now, suppose we want to analyze only the area of the image containing the roots -themselves; -we do not care to look at the kernels, -or anything else about the plants. -Further, we wish to exclude the frame of the container holding the seedlings as well. -Hovering over the image with our mouse, could tell us that -the upper-left coordinate of the sub-area we are interested in is *(44, 357)*, -while the lower-right coordinate is *(720, 740)*. -These coordinates are shown in *(x, y)* order. - -A Python program to create a mask to select only that area of the image would -start with a now-familiar section of code to open and display the original -image: - -```python -# Load and display the original image -maize_seedlings = iio.imread(uri="data/maize-seedlings.tif") - -fig, ax = plt.subplots() -ax.imshow(maize_seedlings) -``` - -We load and display the initial image in the same way we have done before. - -NumPy allows indexing of images/arrays with "boolean" arrays of the same size. -Indexing with a boolean array is also called mask indexing. -The "pixels" in such a mask array can only take two values: `True` or `False`. -When indexing an image with such a mask, -only pixel values at positions where the mask is `True` are accessed. -But first, we need to generate a mask array of the same size as the image. -Luckily, the NumPy library provides a function to create just such an array. -The next section of code shows how: - -```python -# Create the basic mask -mask = np.ones(shape=maize_seedlings.shape[0:2], dtype="bool") -``` - -The first argument to the `ones()` function is the shape of the original image, -so that our mask will be exactly the same size as the original. -Notice, that we have only used the first two indices of our shape. -We omitted the channel dimension. -Indexing with such a mask will change all channel values simultaneously. -The second argument, `dtype = "bool"`, -indicates that the elements in the array should be booleans - -i.e., values are either `True` or `False`. -Thus, even though we use `np.ones()` to create the mask, -its pixel values are in fact not `1` but `True`. -You could check this, e.g., by `print(mask[0, 0])`. - -Next, we draw a filled, rectangle on the mask: - -```python -# Draw filled rectangle on the mask image -rr, cc = ski.draw.rectangle(start=(357, 44), end=(740, 720)) -mask[rr, cc] = False - -# Display mask image -fig, ax = plt.subplots() -ax.imshow(mask, cmap="gray") -``` - -Here is what our constructed mask looks like: -![](fig/maize-seedlings-mask.png){alt='Maize image mask' .image-with-shadow} - -The parameters of the `rectangle()` function `(357, 44)` and `(740, 720)`, -are the coordinates of the upper-left (`start`) and lower-right (`end`) corners -of a rectangle in *(ry, cx)* order. -The function returns the rectangle as row (`rr`) and column (`cc`) coordinate arrays. - -::::::::::::::::::::::::::::::::::::::::: callout - -## Check the documentation! - -When using an scikit-image function for the first time - or the fifth time - -it is wise to check how the function is used, via -[the scikit-image documentation](https://scikit-image.org/docs/dev/user_guide) -or other usage examples on programming-related sites such as -[Stack Overflow](https://stackoverflow.com/). -Basic information about scikit-image functions can be found interactively in Python, -via commands like `help(ski)` or `help(ski.draw.rectangle)`. -Take notes in your lab notebook. -And, it is always wise to run some test code to verify -that the functions your program uses are behaving in the manner you intend. - - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -::::::::::::::::::::::::::::::::::::::::: callout - -## Variable naming conventions! - -You may have wondered why we called the return values of the rectangle function -`rr` and `cc`?! -You may have guessed that `r` is short for `row` and `c` is short for `column`. -However, the rectangle function returns mutiple rows and columns; -thus we used a convention of doubling the letter `r` to `rr` (and `c` to `cc`) -to indicate that those are multiple values. -In fact it may have even been clearer to name those variables `rows` and `columns`; -however this would have been also much longer. -Whatever you decide to do, try to stick to some already existing conventions, -such that it is easier for other people to understand your code. - - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -::::::::::::::::::::::::::::::::::::::: challenge - -## Other drawing operations (15 min) - -There are other functions for drawing on images, -in addition to the `ski.draw.rectangle()` function. -We can draw circles, lines, text, and other shapes as well. -These drawing functions may be useful later on, to help annotate images -that our programs produce. -Practice some of these functions here. - -Circles can be drawn with the `ski.draw.disk()` function, -which takes two parameters: -the (ry, cx) point of the centre of the circle, -and the radius of the circle. -There is an optional `shape` parameter that can be supplied to this function. -It will limit the output coordinates for cases where the circle -dimensions exceed the ones of the image. - -Lines can be drawn with the `ski.draw.line()` function, -which takes four parameters: -the (ry, cx) coordinate of one end of the line, -and the (ry, cx) coordinate of the other end of the line. - -Other drawing functions supported by scikit-image can be found in -[the scikit-image reference pages](https://scikit-image.org/docs/dev/api/skimage.draw.html?highlight=draw#module-skimage.draw). - -First let's make an empty, black image with a size of 800x600 pixels. -Recall that a colour image has three channels for the colours red, green, and blue -(RGB, cf. [Image Basics](03-skimage-images.md)). -Hence we need to create a 3D array of shape `(600, 800, 3)` where the last dimension represents the RGB colour channels. - -```python -# create the black canvas -canvas = np.zeros(shape=(600, 800, 3), dtype="uint8") -``` - -Now your task is to draw some other coloured shapes and lines on the image, -perhaps something like this: - -![](fig/drawing-practice.jpg){alt='Sample shapes'} - -::::::::::::::: solution - -## Solution - -Drawing a circle: - -```python -# Draw a blue circle with centre (200, 300) in (ry, cx) coordinates, and radius 100 -rr, cc = ski.draw.disk(center=(200, 300), radius=100, shape=canvas.shape[0:2]) -canvas[rr, cc] = (0, 0, 255) -``` - -Drawing a line: - -```python -# Draw a green line from (400, 200) to (500, 700) in (ry, cx) coordinates -rr, cc = ski.draw.line(r0=400, c0=200, r1=500, c1=700) -canvas[rr, cc] = (0, 255, 0) -``` - -```python -# Display the image -fig, ax = plt.subplots() -ax.imshow(canvas) -``` - -We could expand this solution, if we wanted, -to draw rectangles, circles and lines at random positions within our black canvas. -To do this, we could use the `random` python module, -and the function `random.randrange`, -which can produce random numbers within a certain range. - -Let's draw 15 randomly placed circles: - -```python -import random - -# create the black canvas -canvas = np.zeros(shape=(600, 800, 3), dtype="uint8") - -# draw a blue circle at a random location 15 times -for i in range(15): - rr, cc = ski.draw.disk(center=( - random.randrange(600), - random.randrange(800)), - radius=50, - shape=canvas.shape[0:2], - ) - canvas[rr, cc] = (0, 0, 255) - -# display the results -fig, ax = plt.subplots() -ax.imshow(canvas) -``` - -We could expand this even further to also -randomly choose whether to plot a rectangle, a circle, or a square. -Again, we do this with the `random` module, -now using the function `random.random` -that returns a random number between 0.0 and 1.0. - -```python -import random - -# Draw 15 random shapes (rectangle, circle or line) at random positions -for i in range(15): - # generate a random number between 0.0 and 1.0 and use this to decide if we - # want a circle, a line or a sphere - x = random.random() - if x < 0.33: - # draw a blue circle at a random location - rr, cc = ski.draw.disk(center=( - random.randrange(600), - random.randrange(800)), - radius=50, - shape=canvas.shape[0:2], - ) - color = (0, 0, 255) - elif x < 0.66: - # draw a green line at a random location - rr, cc = ski.draw.line( - r0=random.randrange(600), - c0=random.randrange(800), - r1=random.randrange(600), - c1=random.randrange(800), - ) - color = (0, 255, 0) - else: - # draw a red rectangle at a random location - rr, cc = ski.draw.rectangle( - start=(random.randrange(600), random.randrange(800)), - extent=(50, 50), - shape=canvas.shape[0:2], - ) - color = (255, 0, 0) - - canvas[rr, cc] = color - -# display the results -fig, ax = plt.subplots() -ax.imshow(canvas) -``` - -::::::::::::::::::::::::: - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -## Image modification - -All that remains is the task of modifying the image using our mask in such a -way that the areas with `True` pixels in the mask are not shown in the image -any more. - -::::::::::::::::::::::::::::::::::::::: challenge - -## How does a mask work? (optional, not included in timing) - -Now, consider the mask image we created above. -The values of the mask that corresponds to the portion of the image -we are interested in are all `False`, -while the values of the mask that corresponds to the portion of the image we -want to remove are all `True`. - -How do we change the original image using the mask? - -::::::::::::::: solution - -## Solution - -When indexing the image using the mask, we access only those pixels at -positions where the mask is `True`. -So, when indexing with the mask, -one can set those values to 0, and effectively remove them from the image. - - - -::::::::::::::::::::::::: - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -Now we can write a Python program to use a mask to retain only the portions -of our maize roots image that actually contains the seedling roots. -We load the original image and create the mask in the same way as before: - -```python -# Load the original image -maize_seedlings = iio.imread(uri="data/maize-seedlings.tif") - -# Create the basic mask -mask = np.ones(shape=maize_seedlings.shape[0:2], dtype="bool") - -# Draw a filled rectangle on the mask image -rr, cc = ski.draw.rectangle(start=(357, 44), end=(740, 720)) -mask[rr, cc] = False -``` - -Then, we use NumPy indexing to remove the portions of the image, -where the mask is `True`: - -```python -# Apply the mask -maize_seedlings[mask] = 0 -``` - -Then, we display the masked image. - -```python -fig, ax = plt.subplots() -ax.imshow(maize_seedlings) -``` - -The resulting masked image should look like this: - -![](fig/maize-seedlings-masked.jpg){alt='Applied mask'} - -::::::::::::::::::::::::::::::::::::::: challenge - -## Masking an image of your own (optional, not included in timing) - -Now, it is your turn to practice. -Using your mobile phone, tablet, webcam, or digital camera, -take an image of an object with a simple overall geometric shape -(think rectangular or circular). -Copy that image to your computer, write some code to make a mask, -and apply it to select the part of the image containing your object. -For example, here is an image of a remote control: - -![](fig/remote-control.jpg){alt='Remote control image'} - -And, here is the end result of a program masking out everything but the remote: - -![](fig/remote-control-masked.jpg){alt='Remote control masked'} - -::::::::::::::: solution - -## Solution - -Here is a Python program to produce the cropped remote control image shown above. -Of course, your program should be tailored to your image. - -```python -# Load the image -remote = iio.imread(uri="data/remote-control.jpg") -remote = np.array(remote) - -# Create the basic mask -mask = np.ones(shape=remote.shape[0:2], dtype="bool") - -# Draw a filled rectangle on the mask image -rr, cc = ski.draw.rectangle(start=(93, 1107), end=(1821, 1668)) -mask[rr, cc] = False - -# Apply the mask -remote[mask] = 0 - -# Display the result -fig, ax = plt.subplots() -ax.imshow(remote) -``` - -::::::::::::::::::::::::: - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -::::::::::::::::::::::::::::::::::::::: challenge - -## Masking a 96-well plate image (30 min) - -Consider this image of a 96-well plate that has been scanned on a flatbed scanner. - -```python -# Load the image -wellplate = iio.imread(uri="data/wellplate-01.jpg") -wellplate = np.array(wellplate) - -# Display the image -fig, ax = plt.subplots() -ax.imshow(wellplate) -``` - -![](fig/wellplate-01.jpg){alt='96-well plate'} - -Suppose that we are interested in the colours of the solutions in each of the wells. -We *do not* care about the colour of the rest of the image, -i.e., the plastic that makes up the well plate itself. - -Your task is to write some code that will produce a mask that will -mask out everything except for the wells. -To help with this, you should use the text file `data/centers.txt` that contains -the (cx, ry) coordinates of the centre of each of the 96 wells in this image. -You may assume that each of the wells has a radius of 16 pixels. - -Your program should produce output that looks like this: - -![](fig/wellplate-01-masked.jpg){alt='Masked 96-well plate'} - -*Hint: You can load `data/centers.txt` using*: - -```Python -# load the well coordinates as a NumPy array -centers = np.loadtxt("data/centers.txt", delimiter=" ") -``` - -::::::::::::::: solution - -## Solution - -```python -# load the well coordinates as a NumPy array -centers = np.loadtxt("data/centers.txt", delimiter=" ") - -# read in original image -wellplate = iio.imread(uri="data/wellplate-01.jpg") -wellplate = np.array(wellplate) - -# create the mask image -mask = np.ones(shape=wellplate.shape[0:2], dtype="bool") - -# iterate through the well coordinates -for cx, ry in centers: - # draw a circle on the mask at the well center - rr, cc = ski.draw.disk(center=(ry, cx), radius=16, shape=wellplate.shape[:2]) - mask[rr, cc] = False - -# apply the mask -wellplate[mask] = 0 - -# display the result -fig, ax = plt.subplots() -ax.imshow(wellplate) -``` - -::::::::::::::::::::::::: - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -::::::::::::::::::::::::::::::::::::::: challenge - -## Masking a 96-well plate image, take two (optional, not included in timing) - -If you spent some time looking at the contents of -the `data/centers.txt` file from the previous challenge, -you may have noticed that the centres of each well in the image are very regular. -*Assuming* that the images are scanned in such a way that -the wells are always in the same place, -and that the image is perfectly oriented -(i.e., it does not slant one way or another), -we could produce our well plate mask without having to -read in the coordinates of the centres of each well. -Assume that the centre of the upper left well in the image is at -location cx = 91 and ry = 108, and that there are -70 pixels between each centre in the cx dimension and -72 pixels between each centre in the ry dimension. -Each well still has a radius of 16 pixels. -Write a Python program that produces the same output image as in the previous challenge, -but *without* having to read in the `centers.txt` file. -*Hint: use nested for loops.* - -::::::::::::::: solution - -## Solution - -Here is a Python program that is able to create the masked image without -having to read in the `centers.txt` file. - -```python -# read in original image -wellplate = iio.imread(uri="data/wellplate-01.jpg") -wellplate = np.array(wellplate) - -# create the mask image -mask = np.ones(shape=wellplate.shape[0:2], dtype="bool") - -# upper left well coordinates -cx0 = 91 -ry0 = 108 - -# spaces between wells -deltaCX = 70 -deltaRY = 72 - -cx = cx0 -ry = ry0 - -# iterate each row and column -for row in range(12): - # reset cx to leftmost well in the row - cx = cx0 - for col in range(8): - - # ... and drawing a circle on the mask - rr, cc = ski.draw.disk(center=(ry, cx), radius=16, shape=wellplate.shape[0:2]) - mask[rr, cc] = False - cx += deltaCX - # after one complete row, move to next row - ry += deltaRY - -# apply the mask -wellplate[mask] = 0 - -# display the result -fig, ax = plt.subplots() -ax.imshow(wellplate) -``` - -::::::::::::::::::::::::: - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -:::::::::::::::::::::::::::::::::::::::: keypoints - -- We can use the NumPy `zeros()` function to create a blank, black image. -- We can draw on scikit-image images with functions such as `ski.draw.rectangle()`, `ski.draw.disk()`, `ski.draw.line()`, and more. -- The drawing functions return indices to pixels that can be set directly. - -:::::::::::::::::::::::::::::::::::::::::::::::::: diff --git a/episodes/05-creating-histograms.md b/episodes/05-creating-histograms.md deleted file mode 100644 index 9d68c5b6e..000000000 --- a/episodes/05-creating-histograms.md +++ /dev/null @@ -1,456 +0,0 @@ ---- -title: Creating Histograms -teaching: 40 -exercises: 40 ---- - -::::::::::::::::::::::::::::::::::::::: objectives - -- Explain what a histogram is. -- Load an image in grayscale format. -- Create and display grayscale and colour histograms for entire images. -- Create and display grayscale and colour histograms for certain areas of images, via masks. - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -:::::::::::::::::::::::::::::::::::::::: questions - -- How can we create grayscale and colour histograms to understand the distribution of colour values in an image? - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -In this episode, we will learn how to use scikit-image functions to create and -display histograms for images. - -## First, import the packages needed for this episode - -```python -import imageio.v3 as iio -import ipympl -import matplotlib.pyplot as plt -import numpy as np -import skimage as ski - -%matplotlib widget -``` - -## Introduction to Histograms - -As it pertains to images, a *histogram* is a graphical representation showing -how frequently various colour values occur in the image. -We saw in -[the *Image Basics* episode](02-image-basics.md) -that we could use a histogram to visualise -the differences in uncompressed and compressed image formats. -If your project involves detecting colour changes between images, -histograms will prove to be very useful, -and histograms are also quite handy as a preparatory step before performing -[thresholding](07-thresholding.md). - -## Grayscale Histograms - -We will start with grayscale images, -and then move on to colour images. -We will use this image of a plant seedling as an example: -![](fig/plant-seedling.jpg){alt='Plant seedling'} - -Here we load the image in grayscale instead of full colour, and display it: - -```python -# read the image of a plant seedling as grayscale from the outset -plant_seedling = iio.imread(uri="data/plant-seedling.jpg", mode="L") - -# convert the image to float dtype with a value range from 0 to 1 -plant_seedling = ski.util.img_as_float(plant_seedling) - -# display the image -fig, ax = plt.subplots() -ax.imshow(plant_seedling, cmap="gray") -``` - -![](fig/plant-seedling-grayscale.png){alt='Plant seedling'} - -Again, we use the `iio.imread()` function to load our image. -Then, we convert the grayscale image of integer dtype, with 0-255 range, into -a floating-point one with 0-1 range, by calling the function -`ski.util.img_as_float`. We can also calculate histograms for 8 bit images as we will see in the -subsequent exercises. - -We now use the function `np.histogram` to compute the histogram of our image -which, after all, is a NumPy array: - -```python -# create the histogram -histogram, bin_edges = np.histogram(plant_seedling, bins=256, range=(0, 1)) -``` - -The parameter `bins` determines the number of "bins" to use for the histogram. -We pass in `256` because we want to see the pixel count for each of -the 256 possible values in the grayscale image. - -The parameter `range` is the range of values each of the pixels in the image can have. -Here, we pass 0 and 1, -which is the value range of our input image after conversion to floating-point. - -The first output of the `np.histogram` function is a one-dimensional NumPy array, -with 256 rows and one column, -representing the number of pixels with the intensity value corresponding to the index. -I.e., the first number in the array is -the number of pixels found with intensity value 0, -and the final number in the array is -the number of pixels found with intensity value 255. -The second output of `np.histogram` is -an array with the bin edges and one column and 257 rows -(one more than the histogram itself). -There are no gaps between the bins, which means that the end of the first bin, -is the start of the second and so on. -For the last bin, the array also has to contain the stop, -so it has one more element, than the histogram. - -Next, we turn our attention to displaying the histogram, -by taking advantage of the plotting facilities of the Matplotlib library. - -```python -# configure and draw the histogram figure -fig, ax = plt.subplots() -ax.set_title("Grayscale Histogram") -ax.set_xlabel("grayscale value") -ax.set_ylabel("pixel count") -ax.set_xlim([0.0, 1.0]) # <- named arguments do not work here - -ax.plot(bin_edges[0:-1], histogram) # <- or here -``` - -We create the plot with `plt.subplots()`, -then label the figure and the coordinate axes with `ax.set_title()`, -`ax.set_xlabel()`, and `ax.set_ylabel()` functions. -The last step in the preparation of the figure is to -set the limits on the values on the x-axis with -the `ax.set_xlim([0.0, 1.0])` function call. - -::::::::::::::::::::::::::::::::::::::::: callout - -## Variable-length argument lists - -Note that we cannot used named parameters for the -`ax.set_xlim()` or `ax.plot()` functions. -This is because these functions are defined to take an arbitrary number of -*unnamed* arguments. -The designers wrote the functions this way because they are very versatile, -and creating named parameters for all of the possible ways to use them -would be complicated. - - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -Finally, we create the histogram plot itself with -`ax.plot(bin_edges[0:-1], histogram)`. -We use the **left** bin edges as x-positions for the histogram values by -indexing the `bin_edges` array to ignore the last value -(the **right** edge of the last bin). -When we run the program on this image of a plant seedling, -it produces this histogram: - -![](fig/plant-seedling-grayscale-histogram.png){alt='Plant seedling histogram'} - -::::::::::::::::::::::::::::::::::::::::: callout - -## Histograms in Matplotlib - -Matplotlib provides a dedicated function to compute and display histograms: -`ax.hist()`. -We will not use it in this lesson in order to understand how to -calculate histograms in more detail. -In practice, it is a good idea to use this function, -because it visualises histograms more appropriately than `ax.plot()`. -Here, you could use it by calling -`ax.hist(image.flatten(), bins=256, range=(0, 1))` -instead of -`np.histogram()` and `ax.plot()` -(`*.flatten()` is a NumPy function that converts our two-dimensional -image into a one-dimensional array). - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -::::::::::::::::::::::::::::::::::::::: challenge - -## Using a mask for a histogram (15 min) - -Looking at the histogram above, -you will notice that there is a large number of very dark pixels, -as indicated in the chart by the spike around the grayscale value 0.12. -That is not so surprising, since the original image is mostly black background. -What if we want to focus more closely on the leaf of the seedling? -That is where a mask enters the picture! - -First, hover over the plant seedling image with your mouse to determine the -*(x, y)* coordinates of a bounding box around the leaf of the seedling. -Then, using techniques from -[the *Drawing and Bitwise Operations* episode](04-drawing.md), -create a mask with a white rectangle covering that bounding box. - -After you have created the mask, apply it to the input image before passing -it to the `np.histogram` function. - -::::::::::::::: solution - -## Solution - -```python - -# read the image as grayscale from the outset -plant_seedling = iio.imread(uri="data/plant-seedling.jpg", mode="L") - -# convert the image to float dtype with a value range from 0 to 1 -plant_seedling = ski.util.img_as_float(plant_seedling) - -# display the image -fig, ax = plt.subplots() -ax.imshow(plant_seedling, cmap="gray") - -# create mask here, using np.zeros() and ski.draw.rectangle() -mask = np.zeros(shape=plant_seedling.shape, dtype="bool") -rr, cc = ski.draw.rectangle(start=(199, 410), end=(384, 485)) -mask[rr, cc] = True - -# display the mask -fig, ax = plt.subplots() -ax.imshow(mask, cmap="gray") - -# mask the image and create the new histogram -histogram, bin_edges = np.histogram(plant_seedling[mask], bins=256, range=(0.0, 1.0)) - -# configure and draw the histogram figure -fig, ax = plt.subplots() - -ax.set_title("Grayscale Histogram") -ax.set_xlabel("grayscale value") -ax.set_ylabel("pixel count") -ax.set_xlim([0.0, 1.0]) -ax.plot(bin_edges[0:-1], histogram) - -``` - -Your histogram of the masked area should look something like this: - -![](fig/plant-seedling-grayscale-histogram-mask.png){alt='Grayscale histogram of masked area'} - - -::::::::::::::::::::::::: - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -## Colour Histograms - -We can also create histograms for full colour images, -in addition to grayscale histograms. -We have seen colour histograms before, -in [the *Image Basics* episode](02-image-basics.md). -A program to create colour histograms starts in a familiar way: - -```python -# read original image, in full color -plant_seedling = iio.imread(uri="data/plant-seedling.jpg") - -# display the image -fig, ax = plt.subplots() -ax.imshow(plant_seedling) -``` - -We read the original image, now in full colour, and display it. - -Next, we create the histogram, by calling the `np.histogram` function three -times, once for each of the channels. -We obtain the individual channels, by slicing the image along the last axis. -For example, we can obtain the red colour channel by calling -`r_chan = image[:, :, 0]`. - -```python -# tuple to select colors of each channel line -colors = ("red", "green", "blue") - -# create the histogram plot, with three lines, one for -# each color -fig, ax = plt.subplots() -ax.set_xlim([0, 256]) -for channel_id, color in enumerate(colors): - histogram, bin_edges = np.histogram( - plant_seedling[:, :, channel_id], bins=256, range=(0, 256) - ) - ax.plot(bin_edges[0:-1], histogram, color=color) - -ax.set_title("Color Histogram") -ax.set_xlabel("Color value") -ax.set_ylabel("Pixel count") -``` - -We will draw the histogram line for each channel in a different colour, -and so we create a tuple of the colours to use for the three lines with the - -`colors = ("red", "green", "blue")` - -line of code. -Then, we limit the range of the x-axis with the `ax.set_xlim()` function call. - -Next, we use the `for` control structure to iterate through the three channels, -plotting an appropriately-coloured histogram line for each. -This may be new Python syntax for you, -so we will take a moment to discuss what is happening in the `for` statement. - -The Python built-in `enumerate()` function takes a list and returns an -*iterator* of *tuples*, where the first element of the tuple is the index and the second element is the element of the list. - -::::::::::::::::::::::::::::::::::::::::: callout - -## Iterators, tuples, and `enumerate()` - -In Python, an *iterator*, or an *iterable object*, is -something that can be iterated over with the `for` control structure. -A *tuple* is a sequence of objects, just like a list. -However, a tuple cannot be changed, -and a tuple is indicated by parentheses instead of square brackets. -The `enumerate()` function takes an iterable object, -and returns an iterator of tuples consisting of -the 0-based index and the corresponding object. - -For example, consider this small Python program: - -```python -list = ("a", "b", "c", "d", "e") - -for x in enumerate(list): - print(x) -``` - -Executing this program would produce the following output: - -```output -(0, 'a') -(1, 'b') -(2, 'c') -(3, 'd') -(4, 'e') -``` - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -In our colour histogram program, we are using a tuple, `(channel_id, color)`, -as the `for` variable. -The first time through the loop, the `channel_id` variable takes the value `0`, -referring to the position of the red colour channel, -and the `color` variable contains the string `"red"`. -The second time through the loop the values are the green channels index `1` and -`"green"`, and the third time they are the blue channel index `2` and `"blue"`. - -Inside the `for` loop, our code looks much like it did for the -grayscale example. We calculate the histogram for the current channel -with the - -`histogram, bin_edges = np.histogram(image[:, :, channel_id], bins=256, range=(0, 256))` - -function call, -and then add a histogram line of the correct colour to the plot with the - -`ax.plot(bin_edges[0:-1], histogram, color=color)` - -function call. -Note the use of our loop variables, `channel_id` and `color`. - -Finally we label our axes and display the histogram, shown here: - -![](fig/plant-seedling-colour-histogram.png){alt='Colour histogram'} - -::::::::::::::::::::::::::::::::::::::: challenge - -## Colour histogram with a mask (25 min) - -We can also apply a mask to the images we apply the colour histogram process to, -in the same way we did for grayscale histograms. -Consider this image of a well plate, -where various chemical sensors have been applied to water and -various concentrations of hydrochloric acid and sodium hydroxide: - -```python -# read the image -wellplate = iio.imread(uri="data/wellplate-02.tif") - -# display the image -fig, ax = plt.subplots() -ax.imshow(wellplate) -``` - -![](fig/wellplate-02.jpg){alt='Well plate image'} - -Suppose we are interested in the colour histogram of one of the sensors in the -well plate image, -specifically, the seventh well from the left in the topmost row, -which shows Erythrosin B reacting with water. - -Hover over the image with your mouse to find the centre of that well -and the radius (in pixels) of the well. -Then create a circular mask to select only the desired well. -Then, use that mask to apply the colour histogram operation to that well. - -Your masked image should look like this: - -![](fig/wellplate-02-masked.jpg){alt='Masked well plate'} - -And, the program should produce a colour histogram that looks like this: - -![](fig/wellplate-02-histogram.png){alt='Well plate histogram'} - -::::::::::::::: solution - -## Solution - -```python -# create a circular mask to select the 7th well in the first row -mask = np.zeros(shape=wellplate.shape[0:2], dtype="bool") -circle = ski.draw.disk(center=(240, 1053), radius=49, shape=wellplate.shape[0:2]) -mask[circle] = 1 - -# just for display: -# make a copy of the image, call it masked_image, and -# zero values where mask is False -masked_img = np.array(wellplate) -masked_img[~mask] = 0 - -# create a new figure and display masked_img, to verify the -# validity of your mask -fig, ax = plt.subplots() -ax.imshow(masked_img) - -# list to select colors of each channel line -colors = ("red", "green", "blue") - -# create the histogram plot, with three lines, one for -# each color -fig, ax = plt.subplots() -ax.set_xlim([0, 256]) -for (channel_id, color) in enumerate(colors): - # use your circular mask to apply the histogram - # operation to the 7th well of the first row - histogram, bin_edges = np.histogram( - wellplate[:, :, channel_id][mask], bins=256, range=(0, 256) - ) - - ax.plot(histogram, color=color) - -ax.set_xlabel("color value") -ax.set_ylabel("pixel count") - -``` - -::::::::::::::::::::::::: - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -:::::::::::::::::::::::::::::::::::::::: keypoints - -- In many cases, we can load images in grayscale by passing the `mode="L"` argument to the `iio.imread()` function. -- We can create histograms of images with the `np.histogram` function. -- We can display histograms using `ax.plot()` with the `bin_edges` and `histogram` values returned by `np.histogram()`. -- The plot can be customised using `ax.set_xlabel()`, `ax.set_ylabel()`, `ax.set_xlim()`, `ax.set_ylim()`, and `ax.set_title()`. -- We can separate the colour channels of an RGB image using slicing operations and create histograms for each colour channel separately. - -:::::::::::::::::::::::::::::::::::::::::::::::::: diff --git a/episodes/06-blurring.md b/episodes/06-blurring.md deleted file mode 100644 index 6aec08157..000000000 --- a/episodes/06-blurring.md +++ /dev/null @@ -1,539 +0,0 @@ ---- -title: Blurring Images -teaching: 35 -exercises: 25 ---- - -::::::::::::::::::::::::::::::::::::::: objectives - -- Explain why applying a low-pass blurring filter to an image is beneficial. -- Apply a Gaussian blur filter to an image using scikit-image. - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -:::::::::::::::::::::::::::::::::::::::: questions - -- How can we apply a low-pass blurring filter to an image? - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -In this episode, we will learn how to use scikit-image functions to blur images. - -When processing an image, we are often interested in identifying objects -represented within it so that we can perform some further analysis of these -objects, e.g., by counting them, measuring their sizes, etc. -An important concept associated with the identification of objects in an image -is that of *edges*: the lines that represent a transition from one group of -similar pixels in the image to another different group. -One example of an edge is the pixels that represent -the boundaries of an object in an image, -where the background of the image ends and the object begins. - -When we blur an image, -we make the colour transition from one side of an edge in the image to another -smooth rather than sudden. -The effect is to average out rapid changes in pixel intensity. -Blurring is a very common operation we need to perform before other tasks such as -[thresholding](07-thresholding.md). -There are several different blurring functions in the `ski.filters` module, -so we will focus on just one here, the *Gaussian blur*. - -::::::::::::::::::::::::::::::::::::::::: callout - -## Filters - -In the day-to-day, macroscopic world, -we have physical filters which separate out objects by size. -A filter with small holes allows only small objects through, -leaving larger objects behind. -This is a good analogy for image filters. -A high-pass filter will retain the smaller details in an image, -filtering out the larger ones. -A low-pass filter retains the larger features, -analogous to what's left behind by a physical filter mesh. -*High-* and *low*-pass, here, -refer to high and low *spatial frequencies* in the image. -Details associated with high spatial frequencies are small, -a lot of these features would fit across an image. -Features associated with low spatial frequencies are large - -maybe a couple of big features per image. - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -::::::::::::::::::::::::::::::::::::::::: callout - -## Blurring - -To blur is to make something less clear or distinct. -This could be interpreted quite broadly in the context of image analysis - -anything that reduces or distorts the detail of an image might apply. -Applying a low-pass filter, which removes detail occurring at high spatial frequencies, -is perceived as a blurring effect. -A Gaussian blur is a filter that makes use of a Gaussian kernel. - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -::::::::::::::::::::::::::::::::::::::::: callout - -## Kernels - -A kernel can be used to implement a filter on an image. -A kernel, in this context, -is a small matrix which is combined with the image using -a mathematical technique: *convolution*. -Different sizes, shapes and contents of kernel produce different effects. -The kernel can be thought of as a little image in itself, -and will favour features of similar size and shape in the main image. -On convolution with an image, a big, blobby kernel will retain -big, blobby, low spatial frequency features. - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -## Gaussian blur - -Consider this image of a cat, -in particular the area of the image outlined by the white square. - -![](fig/cat.jpg){alt='Cat image'} - -Now, zoom in on the area of the cat's eye, as shown in the left-hand image below. -When we apply a filter, we consider each pixel in the image, one at a time. -In this example, the pixel we are currently working on is highlighted in red, -as shown in the right-hand image. - -![](fig/cat-eye-pixels.jpg){alt='Cat eye pixels'} - -When we apply a filter, we consider rectangular groups of pixels surrounding -each pixel in the image, in turn. -The *kernel* is another group of pixels (a separate matrix / small image), -of the same dimensions as the rectangular group of pixels in the image, -that moves along with the pixel being worked on by the filter. -The width and height of the kernel must be an odd number, -so that the pixel being worked on is always in its centre. -In the example shown above, the kernel is square, with a dimension of seven pixels. - -To apply the kernel to the current pixel, -an average of the colour values of the pixels surrounding it is calculated, -weighted by the values in the kernel. -In a Gaussian blur, the pixels nearest the centre of the kernel are -given more weight than those far away from the centre. -The rate at which this weight diminishes is determined by a Gaussian function, hence the name -Gaussian blur. - -A Gaussian function maps random variables into a normal distribution or "Bell Curve". -![](fig/Normal_Distribution_PDF.svg){alt='Gaussian function'} - -| *[https://en.wikipedia.org/wiki/Gaussian\_function#/media/File:Normal\_Distribution\_PDF.svg](https://en.wikipedia.org/wiki/Gaussian_function#/media/File:Normal_Distribution_PDF.svg)* | - -The shape of the function is described by a mean value μ, and a variance value σ². The mean determines the central point of the bell curve on the X axis, and the variance describes the spread of the curve. - -In fact, when using Gaussian functions in Gaussian blurring, we use a 2D Gaussian function to account for X and Y dimensions, but the same rules apply. The mean μ is always 0, and represents the middle of the 2D kernel. Increasing values of σ² in either dimension increases the amount of blurring in that dimension. - -![](fig/Gaussian_2D.png){alt='2D Gaussian function'} - -| *[https://commons.wikimedia.org/wiki/File:Gaussian\_2D.png](https://commons.wikimedia.org/wiki/File:Gaussian_2D.png)* | - -The averaging is done on a channel-by-channel basis, -and the average channel values become the new value for the pixel in -the filtered image. -Larger kernels have more values factored into the average, and this implies -that a larger kernel will blur the image more than a smaller kernel. - -To get an idea of how this works, -consider this plot of the two-dimensional Gaussian function: - -![](fig/gaussian-kernel.png){alt='2D Gaussian function'} - -Imagine that plot laid over the kernel for the Gaussian blur filter. -The height of the plot corresponds to the weight given to the underlying pixel -in the kernel. -I.e., the pixels close to the centre become more important to -the filtered pixel colour than the pixels close to the outer limits of the kernel. -The shape of the Gaussian function is controlled via its standard deviation, -or sigma. -A large sigma value results in a flatter shape, -while a smaller sigma value results in a more pronounced peak. -The mathematics involved in the Gaussian blur filter are not quite that simple, -but this explanation gives you the basic idea. - -To illustrate the blurring process, -consider the blue channel colour values from the seven-by-seven region -of the cat image above: - -![](fig/cat-corner-blue.png){alt='Image corner pixels'} - -The filter is going to determine the new blue channel value for the centre -pixel -- the one that currently has the value 86. The filter calculates a -weighted average of all the blue channel values in the kernel -giving higher weight to the pixels near the centre of the -kernel. - -![](fig/combination.png){alt='Image multiplication'} - -This weighted average, the sum of the multiplications, -becomes the new value for the centre pixel (3, 3). -The same process would be used to determine the green and red channel values, -and then the kernel would be moved over to apply the filter to the -next pixel in the image. - -:::::::::::::::::::::::::::::::::::::::: instructor - -## Terminology about image boundaries - -Take care to avoid mixing up the term "edge" to describe the edges of objects -*within* an image and the outer boundaries of the images themselves. -Lack of a clear distinction here may be confusing for learners. - -::::::::::::::::::::::::::::::::::::::::::::::::::: - -::::::::::::::::::::::::::::::::::::::::: callout - -## Image edges - -Something different needs to happen for pixels near the outer limits of the image, -since the kernel for the filter may be partially off the image. -For example, what happens when the filter is applied to -the upper-left pixel of the image? -Here are the blue channel pixel values for the upper-left pixel of the cat image, -again assuming a seven-by-seven kernel: - -```output - x x x x x x x - x x x x x x x - x x x x x x x - x x x 4 5 9 2 - x x x 5 3 6 7 - x x x 6 5 7 8 - x x x 5 4 5 3 -``` - -The upper-left pixel is the one with value 4. -Since the pixel is at the upper-left corner, -there are no pixels underneath much of the kernel; -here, this is represented by x's. -So, what does the filter do in that situation? - -The default mode is to fill in the *nearest* pixel value from the image. -For each of the missing x's the image value closest to the x is used. -If we fill in a few of the missing pixels, you will see how this works: - -```output - x x x 4 x x x - x x x 4 x x x - x x x 4 x x x - 4 4 4 4 5 9 2 - x x x 5 3 6 7 - x x x 6 5 7 8 - x x x 5 4 5 3 -``` - -Another strategy to fill those missing values is -to *reflect* the pixels that are in the image to fill in for the pixels that -are missing from the kernel. - -```output - x x x 5 x x x - x x x 6 x x x - x x x 5 x x x - 2 9 5 4 5 9 2 - x x x 5 3 6 7 - x x x 6 5 7 8 - x x x 5 4 5 3 -``` - -A similar process would be used to fill in all of the other missing pixels from -the kernel. Other *border modes* are available; you can learn more about them -in [the scikit-image documentation](https://scikit-image.org/docs/dev/user_guide). - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -Let's consider a very simple image to see blurring in action. The animation below shows how the blur kernel (large red square) moves along the image on the left in order to calculate the corresponding values for the blurred image (yellow central square) on the right. In this simple case, the original image is single-channel, but blurring would work likewise on a multi-channel image. - -![](fig/blur-demo.gif){alt='Blur demo animation'} - -scikit-image has built-in functions to perform blurring for us, so we do not have to -perform all of these mathematical operations ourselves. Let's work through -an example of blurring an image with the scikit-image Gaussian blur function. - -First, import the packages needed for this episode: - -```python -import imageio.v3 as iio -import ipympl -import matplotlib.pyplot as plt -import skimage as ski - -%matplotlib widget -``` - -Then, we load the image, and display it: - -```python -image = iio.imread(uri="data/gaussian-original.png") - -# display the image -fig, ax = plt.subplots() -ax.imshow(image) -``` - -![](fig/gaussian-original.png){alt='Original image'} - -Next, we apply the gaussian blur: - -```python -sigma = 3.0 - -# apply Gaussian blur, creating a new image -blurred = ski.filters.gaussian( - image, sigma=(sigma, sigma), truncate=3.5, channel_axis=-1) -``` - -The first two arguments to `ski.filters.gaussian()` are the image to blur, -`image`, and a tuple defining the sigma to use in ry- and cx-direction, -`(sigma, sigma)`. -The third parameter `truncate` is meant to pass the radius of the kernel in -number of sigmas. -A Gaussian function is defined from -infinity to +infinity, but our kernel -(which must have a finite, smaller size) can only approximate the real function. -Therefore, we must choose a certain distance from the centre of the function -where we stop this approximation, and set the final size of our kernel. -In the above example, we set `truncate` to 3.5, -which means the kernel size will be 2 \* sigma \* 3.5. -For example, for a `sigma` of 1.0 the resulting kernel size would be 7, -while for a `sigma` of 2.0 the kernel size would be 14. -The default value for `truncate` in scikit-image is 4.0. - -The last argument we passed to `ski.filters.gaussian()` is used to -specify the dimension which contains the (colour) channels. -Here, it is the last dimension; -recall that, in Python, the `-1` index refers to the last position. -In this case, the last dimension is the third dimension (index `2`), since our -image has three dimensions: - -```python -print(image.ndim) -``` - -```output -3 -``` - -Finally, we display the blurred image: - -```python -# display blurred image -fig, ax = plt.subplots() -ax.imshow(blurred) -``` - -![](fig/gaussian-blurred.png){alt='Blurred image'} - - -## Visualising Blurring - -Somebody said once "an image is worth a thousand words". -What is actually happening to the image pixels when we apply blurring may be -difficult to grasp. Let's now visualise the effects of blurring from a different -perspective. - -Let's use the petri-dish image from previous episodes: - -![ -Graysacle version of the Petri dish image -](fig/petri-dish.png){alt='Bacteria colony'} - -What we want to see here is the pixel intensities from a lateral perspective: -we want to see the profile of intensities. -For instance, let's look for the intensities of the pixels along the horizontal -line at `Y=150`: - -```python -# read colonies color image and convert to grayscale -image = iio.imread('data/colonies-01.tif') -image_gray = ski.color.rgb2gray(image) - -# define the pixels for which we want to view the intensity (profile) -xmin, xmax = (0, image_gray.shape[1]) -Y = ymin = ymax = 150 - -# view the image indicating the profile pixels position -fig, ax = plt.subplots() -ax.imshow(image_gray, cmap='gray') -ax.plot([xmin, xmax], [ymin, ymax], color='red') -``` - -![ -Grayscale Petri dish image marking selected pixels for profiling -](fig/petri-selected-pixels-marker.png){ -alt='Bacteria colony image with selected pixels marker' -} - -The intensity of those pixels we can see with a simple line plot: - -```python -# select the vector of pixels along "Y" -image_gray_pixels_slice = image_gray[Y, :] - -# guarantee the intensity values are in the [0:255] range (unsigned integers) -image_gray_pixels_slice = ski.img_as_ubyte(image_gray_pixels_slice) - -fig, ax = plt.subplots() -ax.plot(image_gray_pixels_slice, color='red') -ax.set_ylim(255, 0) -ax.set_ylabel('L') -ax.set_xlabel('X') -``` - -![ -Intensities profile line plot of pixels along Y=150 in original image -](fig/petri-original-intensities-plot.png){ -alt='Pixel intensities profile in original image' -} - -And now, how does the same set of pixels look in the corresponding *blurred* image: - -```python -# first, create a blurred version of (grayscale) image -image_blur = ski.filters.gaussian(image_gray, sigma=3) - -# like before, plot the pixels profile along "Y" -image_blur_pixels_slice = image_blur[Y, :] -image_blur_pixels_slice = ski.img_as_ubyte(image_blur_pixels_slice) - -fig, ax = plt.subplots() -ax.plot(image_blur_pixels_slice, 'red') -ax.set_ylim(255, 0) -ax.set_ylabel('L') -ax.set_xlabel('X') -``` - -![ -Intensities profile of pixels along Y=150 in *blurred* image -](fig/petri-blurred-intensities-plot.png){ -alt='Pixel intensities profile in blurred image' -} - -And that is why *blurring* is also called *smoothing*. -This is how low-pass filters affect neighbouring pixels. - -Now that we have seen the effects of blurring an image from -two different perspectives, front and lateral, let's take -yet another look using a 3D visualisation. - -:::::::::::::::::::::::::::::::::::::::::: callout - -### 3D Plots with matplotlib -The code to generate these 3D plots is outside the scope of this lesson -but can be viewed by following the links in the captions. - -:::::::::::::::::::::::::::::::::::::::::::::::::: - - -![ -A 3D plot of pixel intensities across the whole Petri dish image before blurring. -[Explore how this plot was created with matplotlib](https://gist.github.com/chbrandt/63ba38142630a0586ba2a13eabedf94b). -Image credit: [Carlos H Brandt](https://github.com/chbrandt/). -](fig/3D_petri_before_blurring.png){ -alt='3D surface plot showing pixel intensities across the whole example Petri dish image before blurring' -} - -![ -A 3D plot of pixel intensities after Gaussian blurring of the Petri dish image. -Note the 'smoothing' effect on the pixel intensities of the colonies in the image, -and the 'flattening' of the background noise at relatively low pixel intensities throughout the image. -[Explore how this plot was created with matplotlib](https://gist.github.com/chbrandt/63ba38142630a0586ba2a13eabedf94b). -Image credit: [Carlos H Brandt](https://github.com/chbrandt/). -](fig/3D_petri_after_blurring.png){ -alt='3D surface plot illustrating the smoothing effect on pixel intensities across the whole example Petri dish image after blurring' -} - - - -::::::::::::::::::::::::::::::::::::::: challenge - -## Experimenting with sigma values (10 min) - -The size and shape of the kernel used to blur an image can have a -significant effect on the result of the blurring and any downstream analysis -carried out on the blurred image. -The next two exercises ask you to experiment with the sigma values of the kernel, -which is a good way to develop your understanding of how the choice of kernel -can influence the result of blurring. - -First, try running the code above with a range of smaller and larger sigma values. -Generally speaking, what effect does the sigma value have on the -blurred image? - -::::::::::::::: solution - -## Solution - -Generally speaking, the larger the sigma value, the more blurry the result. -A larger sigma will tend to get rid of more noise in the image, which will -help for other operations we will cover soon, such as thresholding. -However, a larger sigma also tends to eliminate some of the detail from -the image. So, we must strike a balance with the sigma value used for -blur filters. - - - -::::::::::::::::::::::::: - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -::::::::::::::::::::::::::::::::::::::: challenge - -## Experimenting with kernel shape (10 min - optional, not included in timing) - -Now, what is the effect of applying an asymmetric kernel to blurring an image? -Try running the code above with different sigmas in the ry and cx direction. -For example, a sigma of 1.0 in the ry direction, and 6.0 in the cx direction. - -::::::::::::::: solution - -## Solution - -```python -# apply Gaussian blur, with a sigma of 1.0 in the ry direction, and 6.0 in the cx direction -blurred = ski.filters.gaussian( - image, sigma=(1.0, 6.0), truncate=3.5, channel_axis=-1 -) - -# display blurred image -fig, ax = plt.subplots() -ax.imshow(blurred) -``` - -![](fig/rectangle-gaussian-blurred.png){alt='Rectangular kernel blurred image'} - -These unequal sigma values produce a kernel that is rectangular instead of square. -The result is an image that is much more blurred in the X direction than in the -Y direction. -For most use cases, a uniform blurring effect is desirable and -this kind of asymmetric blurring should be avoided. -However, it can be helpful in specific circumstances, e.g., when noise is present in -your image in a particular pattern or orientation, such as vertical lines, -or when you want to -[remove uniform noise without blurring edges present in the image in a particular orientation](https://www.researchgate.net/publication/228567435_An_edge_detection_algorithm_based_on_rectangular_Gaussian_kernels_for_machine_vision_applications). - -::::::::::::::::::::::::: - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -## Other methods of blurring - -The Gaussian blur is a way to apply a low-pass filter in scikit-image. -It is often used to remove Gaussian (i.e., random) noise in an image. -For other kinds of noise, e.g., "salt and pepper", a -median filter is typically used. -See [the `skimage.filters` documentation](https://scikit-image.org/docs/dev/api/skimage.filters.html#module-skimage.filters) -for a list of available filters. - -:::::::::::::::::::::::::::::::::::::::: keypoints - -- Applying a low-pass blurring filter smooths edges and removes noise from an image. -- Blurring is often used as a first step before we perform thresholding or edge detection. -- The Gaussian blur can be applied to an image with the `ski.filters.gaussian()` function. -- Larger sigma values may remove more noise, but they will also remove detail from an image. - -:::::::::::::::::::::::::::::::::::::::::::::::::: diff --git a/episodes/07-thresholding.md b/episodes/07-thresholding.md deleted file mode 100644 index e8f94ba23..000000000 --- a/episodes/07-thresholding.md +++ /dev/null @@ -1,780 +0,0 @@ ---- -title: Thresholding -teaching: 60 -exercises: 50 ---- - -::::::::::::::::::::::::::::::::::::::: objectives - -- Explain what thresholding is and how it can be used. -- Use histograms to determine appropriate threshold values to use for the thresholding process. -- Apply simple, fixed-level binary thresholding to an image. -- Explain the difference between using the operator `>` or the operator `<` to threshold an image represented by a NumPy array. -- Describe the shape of a binary image produced by thresholding via `>` or `<`. -- Explain when Otsu's method for automatic thresholding is appropriate. -- Apply automatic thresholding to an image using Otsu's method. -- Use the `np.count_nonzero()` function to count the number of non-zero pixels in an image. - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -:::::::::::::::::::::::::::::::::::::::: questions - -- How can we use thresholding to produce a binary image? - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -In this episode, we will learn how to use scikit-image functions to apply -thresholding to an image. -Thresholding is a type of *image segmentation*, -where an image is split into different regions, or *segments*. -These segments can then be analyzed separately. - -In thresholding, we convert an image from colour or grayscale into -a *binary image*, i.e., one that is simply black and white. -Most frequently, -we use thresholding as a way to select areas of interest of an image, -while ignoring the parts we are not concerned with. -We have already done some simple thresholding, -in the "Manipulating pixels" section of -[the *Working with scikit-image* episode](03-skimage-images.md). -In that case, we used a simple NumPy array manipulation to -separate the pixels belonging to the root system of a plant from the black background. -In this episode, we will learn how to use scikit-image functions to perform thresholding. -Then, we will use the masks returned by these functions to -select the parts of an image we are interested in. - -## First, import the packages needed for this episode - -```python -import glob - -import imageio.v3 as iio -import ipympl -import matplotlib.pyplot as plt -import numpy as np -import skimage as ski - -%matplotlib widget -``` - -## Simple thresholding - -Consider the image `data/shapes-01.jpg` with a series of -crudely cut shapes set against a white background. - -```python -# load the image -shapes01 = iio.imread(uri="data/shapes-01.jpg") - -fig, ax = plt.subplots() -ax.imshow(shapes01) -``` - -![](fig/shapes-01.jpg){alt='Image with geometric shapes on white background' .image-with-shadow} - -Now suppose we want to select only the shapes from the image. -In other words, we want to leave the pixels belonging to the shapes "on," -while turning the rest of the pixels "off," -by setting their colour channel values to zeros. -The scikit-image library has several different methods of thresholding. -We will start with the simplest version, -which involves an important step of human input. -Specifically, in this simple, *fixed-level thresholding*, -we have to provide a threshold value `t`. - -The process works like this. -First, we will load the original image, convert it to grayscale, -and de-noise it as in [the *Blurring Images* episode](06-blurring.md). - -```python -# convert the image to grayscale -gray_shapes = ski.color.rgb2gray(shapes01) - -# blur the image to denoise -blurred_shapes = ski.filters.gaussian(gray_shapes, sigma=1.0) - -fig, ax = plt.subplots() -ax.imshow(blurred_shapes, cmap="gray") -``` - -![](fig/shapes-01-grayscale.png){alt='Grayscale image of the geometric shapes' .image-with-shadow} - -Next, we would like to apply the threshold `t` such that -pixels with grayscale values on one side of `t` will be turned "on", -while pixels with grayscale values on the other side will be turned "off". -How might we do that? -Remember that grayscale images contain pixel values in the range from 0 to 1, -so we are looking for a threshold `t` in the closed range [0\.0, 1.0]. -We see in the image that the geometric shapes are "darker" than -the white background but there is also some light gray noise on the background. -One way to determine a "good" value for `t` is -to look at the grayscale histogram of the image -and try to identify what grayscale ranges correspond to the shapes in the image -or the background. - -The histogram for the shapes image shown above can be produced as in -[the *Creating Histograms* episode](05-creating-histograms.md). - -```python -# create a histogram of the blurred grayscale image -histogram, bin_edges = np.histogram(blurred_shapes, bins=256, range=(0.0, 1.0)) - -fig, ax = plt.subplots() -ax.plot(bin_edges[0:-1], histogram) -ax.set_title("Grayscale Histogram") -ax.set_xlabel("grayscale value") -ax.set_ylabel("pixels") -ax.set_xlim(0, 1.0) -``` - -![](fig/shapes-01-histogram.png){alt='Grayscale histogram of the geometric shapes image'} - -Since the image has a white background, -most of the pixels in the image are white. -This corresponds nicely to what we see in the histogram: -there is a peak near the value of 1.0. -If we want to select the shapes and not the background, -we want to turn off the white background pixels, -while leaving the pixels for the shapes turned on. -So, we should choose a value of `t` somewhere before the large peak and -turn pixels above that value "off". -Let us choose `t=0.8`. - -To apply the threshold `t`, -we can use the NumPy comparison operators to create a mask. -Here, we want to turn "on" all pixels which have values smaller than the threshold, -so we use the less operator `<` to compare the `blurred_image` to the threshold `t`. -The operator returns a mask, that we capture in the variable `binary_mask`. -It has only one channel, and each of its values is either 0 or 1. -The binary mask created by the thresholding operation can be shown with `ax.imshow`, -where the `False` entries are shown as black pixels -(0-valued) and the `True` entries are shown as white pixels -(1-valued). - -```python -# create a mask based on the threshold -t = 0.8 -binary_mask = blurred_shapes < t - -fig, ax = plt.subplots() -ax.imshow(binary_mask, cmap="gray") -``` - -![](fig/shapes-01-mask.png){alt='Binary mask of the geometric shapes created by thresholding'} - -You can see that the areas where the shapes were in the original area are now white, -while the rest of the mask image is black. - -::::::::::::::::::::::::::::::::::::::::: callout - -## What makes a good threshold? - -As is often the case, the answer to this question is "it depends". -In the example above, we could have just switched off all -the white background pixels by choosing `t=1.0`, -but this would leave us with some background noise in the mask image. -On the other hand, if we choose too low a value for the threshold, -we could lose some of the shapes that are too bright. -You can experiment with the threshold by re-running the above code lines with -different values for `t`. -In practice, it is a matter of domain knowledge and -experience to interpret the peaks in the histogram so to determine -an appropriate threshold. -The process often involves trial and error, -which is a drawback of the simple thresholding method. -Below we will introduce automatic thresholding, -which uses a quantitative, mathematical definition for a good threshold that -allows us to determine the value of `t` automatically. -It is worth noting that the principle for simple and automatic thresholding -can also be used for images with pixel ranges other than [0\.0, 1.0]. -For example, we could perform thresholding on pixel intensity values -in the range [0, 255] as we have already seen in -[the *Working with scikit-image* episode](03-skimage-images.md). - - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -We can now apply the `binary_mask` to the original coloured image as we -have learned in [the *Drawing and Bitwise Operations* episode](04-drawing.md). -What we are left with is only the coloured shapes from the original. - -```python -# use the binary_mask to select the "interesting" part of the image -selection = shapes01.copy() -selection[~binary_mask] = 0 - -fig, ax = plt.subplots() -ax.imshow(selection) -``` - -![](fig/shapes-01-selected.png){alt='Selected shapes after applying binary mask'} - -::::::::::::::::::::::::::::::::::::::: challenge - -## More practice with simple thresholding (15 min) - -Now, it is your turn to practice. Suppose we want to use simple thresholding -to select only the coloured shapes (in this particular case we consider grayish to be a colour, too) from the image `data/shapes-02.jpg`: - -![](fig/shapes-02.jpg){alt='Another image with geometric shapes on white background'} - -First, plot the grayscale histogram as in the [Creating -Histogram](05-creating-histograms.md) episode and -examine the distribution of grayscale values in the image. What do -you think would be a good value for the threshold `t`? - -::::::::::::::: solution - -## Solution - -The histogram for the `data/shapes-02.jpg` image can be shown with - -```python -shapes = iio.imread(uri="data/shapes-02.jpg") -gray_shapes = ski.color.rgb2gray(shapes) -histogram, bin_edges = np.histogram(gray_shapes, bins=256, range=(0.0, 1.0)) - -fig, ax = plt.subplots() -ax.plot(bin_edges[0:-1], histogram) -ax.set_title("Graylevel histogram") -ax.set_xlabel("gray value") -ax.set_ylabel("pixel count") -ax.set_xlim(0, 1.0) -``` - -![](fig/shapes-02-histogram.png){alt='Grayscale histogram of the second geometric shapes image'} - -We can see a large spike around 0.3, and a smaller spike around 0.7. The -spike near 0.3 represents the darker background, so it seems like a value -close to `t=0.5` would be a good choice. - - -::::::::::::::::::::::::: - -Next, create a mask to turn the pixels above the threshold `t` on -and pixels below the threshold `t` off. Note that unlike the image -with a white background we used above, here the peak for the -background colour is at a lower gray level than the -shapes. Therefore, change the comparison operator less `<` to -greater `>` to create the appropriate mask. Then apply the mask to -the image and view the thresholded image. If everything works as it -should, your output should show only the coloured shapes on a black -background. - -::::::::::::::: solution - -## Solution - -Here are the commands to create and view the binary mask - -```python -t = 0.5 -binary_mask = gray_shapes > t - -fig, ax = plt.subplots() -ax.imshow(binary_mask, cmap="gray") -``` - -![](fig/shapes-02-mask.png){alt='Binary mask created by thresholding the second geometric shapes image'} - -And here are the commands to apply the mask and view the thresholded image - -```python -shapes02 = iio.imread(uri="data/shapes-02.jpg") -selection = shapes02.copy() -selection[~binary_mask] = 0 - -fig, ax = plt.subplots() -ax.imshow(selection) -``` - -![](fig/shapes-02-selected.png){alt='Selected shapes after applying binary mask to the second geometric shapes image'} - -::::::::::::::::::::::::: - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -## Automatic thresholding - -The downside of the simple thresholding technique is that we have to -make an educated guess about the threshold `t` by inspecting the histogram. -There are also *automatic thresholding* methods that can determine -the threshold automatically for us. -One such method is *[Otsu's method](https://en.wikipedia.org/wiki/Otsu%27s_method)*. -It is particularly useful for situations where the grayscale histogram of an image -has two peaks that correspond to background and objects of interest. - -::::::::::::::::::::::::::::::::::::::::: callout - -## Denoising an image before thresholding - -In practice, it is often necessary to denoise the image before -thresholding, which can be done with one of the methods from -[the *Blurring Images* episode](06-blurring.md). - - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -Consider the image `data/maize-root-cluster.jpg` of a maize root system which -we have seen before in -[the *Working with scikit-image* episode](03-skimage-images.md). - -```python -maize_roots = iio.imread(uri="data/maize-root-cluster.jpg") - -fig, ax = plt.subplots() -ax.imshow(maize_roots) -``` - -![](fig/maize-root-cluster.jpg){alt='Image of a maize root'} - -We use Gaussian blur with a sigma of 1.0 to denoise the root image. -Let us look at the grayscale histogram of the denoised image. - -```python -# convert the image to grayscale -gray_image = ski.color.rgb2gray(maize_roots) - -# blur the image to denoise -blurred_image = ski.filters.gaussian(gray_image, sigma=1.0) - -# show the histogram of the blurred image -histogram, bin_edges = np.histogram(blurred_image, bins=256, range=(0.0, 1.0)) -fig, ax = plt.subplots() -ax.plot(bin_edges[0:-1], histogram) -ax.set_title("Graylevel histogram") -ax.set_xlabel("gray value") -ax.set_ylabel("pixel count") -ax.set_xlim(0, 1.0) -``` - -![](fig/maize-root-cluster-histogram.png){alt='Grayscale histogram of the maize root image'} - -The histogram has a significant peak around 0.2 and then a broader "hill" around 0.6 followed by a -smaller peak near 1.0. Looking at the grayscale image, we can identify the peak at 0.2 with the -background and the broader peak with the foreground. -Thus, this image is a good candidate for thresholding with Otsu's method. -The mathematical details of how this works are complicated (see -[the scikit-image documentation](https://scikit-image.org/docs/dev/api/skimage.filters.html#threshold-otsu) -if you are interested), -but the outcome is that Otsu's method finds a threshold value between the two peaks of a grayscale -histogram which might correspond well to the foreground and background depending on the data and -application. - -:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: instructor - -The histogram of the maize root image may prompt questions from learners about the interpretation -of the peaks and the broader region around 0.6. The focus here is on the separation of background -and foreground pixel values. We note that Otsu's method does not work well -for the image with the shapes used earlier in this episode, as the foreground pixel values are more -distributed. These examples could be augmented with a discussion of unimodal, bimodal, and multimodal -histograms. While these points can lead to fruitful considerations, the text in this episode attempts -to reduce cognitive load and deliberately simplifies the discussion. - -:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: - -The `ski.filters.threshold_otsu()` function can be used to determine -the threshold automatically via Otsu's method. -Then NumPy comparison operators can be used to apply it as before. -Here are the Python commands to determine the threshold `t` with Otsu's method. - -```python -# perform automatic thresholding -t = ski.filters.threshold_otsu(blurred_image) -print("Found automatic threshold t = {}.".format(t)) -``` - -```output -Found automatic threshold t = 0.4116003928683858. -``` - -For this root image and a Gaussian blur with the chosen sigma of 1.0, -the computed threshold value is 0.42. -No we can create a binary mask with the comparison operator `>`. -As we have seen before, pixels above the threshold value will be turned on, -those below the threshold will be turned off. - -```python -# create a binary mask with the threshold found by Otsu's method -binary_mask = blurred_image > t - -fig, ax = plt.subplots() -ax.imshow(binary_mask, cmap="gray") -``` - -![](fig/maize-root-cluster-mask.png){alt='Binary mask of the maize root system'} - -Finally, we use the mask to select the foreground: - -```python -# apply the binary mask to select the foreground -selection = maize_roots.copy() -selection[~binary_mask] = 0 - -fig, ax = plt.subplots() -ax.imshow(selection) -``` - -![](fig/maize-root-cluster-selected.png){alt='Masked selection of the maize root system'} - -## Application: measuring root mass - -Let us now turn to an application where we can apply thresholding and -other techniques we have learned to this point. -Consider these four maize root system images, -which you can find in the files -`data/trial-016.jpg`, -`data/trial-020.jpg`, -`data/trial-216.jpg`, -and `data/trial-293.jpg`. - -![](fig/four-maize-roots.jpg){alt='Four images of maize roots'} - -Suppose we are interested in the amount of plant material in each image, -and in particular how that amount changes from image to image. -Perhaps the images represent the growth of the plant over time, -or perhaps the images show four different maize varieties at the -same phase of their growth. -The question we would like to answer is, "how much root mass is in each image?" - -We will first construct a Python program to measure this value for a single image. -Our strategy will be this: - -1. Read the image, converting it to grayscale as it is read. For this - application we do not need the colour image. -2. Blur the image. -3. Use Otsu's method of thresholding to create a binary image, where - the pixels that were part of the maize plant are white, and everything - else is black. -4. Save the binary image so it can be examined later. -5. Count the white pixels in the binary image, and divide by the - number of pixels in the image. This ratio will be a measure of the - root mass of the plant in the image. -6. Output the name of the image processed and the root mass ratio. - -Our intent is to perform these steps and produce the numeric result - -a measure of the root mass in the image - -without human intervention. -Implementing the steps within a Python function will -enable us to call this function for different images. - -Here is a Python function that implements this root-mass-measuring strategy. -Since the function is intended to produce numeric output without human interaction, -it does not display any of the images. -Almost all of the commands should be familiar, and in fact, -it may seem simpler than the code we have worked on thus far, -because we are not displaying any of the images. - -```python -def measure_root_mass(filename, sigma=1.0): - - # read the original image, converting to grayscale on the fly - image = iio.imread(uri=filename, mode="L") - - # blur before thresholding - blurred_image = ski.filters.gaussian(image, sigma=sigma) - - # perform automatic thresholding to produce a binary image - t = ski.filters.threshold_otsu(blurred_image) - binary_mask = blurred_image > t - - # determine root mass ratio - root_pixels = np.count_nonzero(binary_mask) - density = root_pixels / binary_mask.size - - return density -``` - -The function begins with reading the original image from the file `filename`. -We use `iio.imread()` with the optional argument `mode="L"` to -automatically convert it to grayscale. -Next, the grayscale image is blurred with a Gaussian filter with -the value of `sigma` that is passed to the function. -Then we determine the threshold `t` with Otsu's method and -create a binary mask just as we did in the previous section. -Up to this point, everything should be familiar. - -The final part of the function determines the root mass ratio in the image. -Recall that in the `binary_mask`, every pixel has either a value of -zero (black/background) or one (white/foreground). -We want to count the number of white pixels, -which can be accomplished with a call to the NumPy function `np.count_nonzero`. -Finally, the density ratio is calculated by dividing the number of white pixels -by the total number of pixels `binary_mask.size` in the image. -The function returns then root density of the image. - -We can call this function with any filename and -provide a sigma value for the blurring. -If no sigma value is provided, the default value 1.0 will be used. -For example, for the file `data/trial-016.jpg` and a sigma value of 1.5, -we would call the function like this: - -```python -measure_root_mass(filename="data/trial-016.jpg", sigma=1.5) -``` - -```output -0.04907247340425532 -``` - -Now we can use the function to process the series of four images shown above. -In a real-world scientific situation, there might be dozens, hundreds, -or even thousands of images to process. -To save us the tedium of calling the function for each image by hand, -we can write a loop that processes all files automatically. -The following code block assumes that the files are located in the same directory -and the filenames all start with the **trial-** prefix and -end with the **.jpg** suffix. - -```python -all_files = sorted(glob.glob("data/trial-*.jpg")) -for filename in all_files: - density = measure_root_mass(filename=filename, sigma=1.5) - # output in format suitable for .csv - print(filename, density, sep=",") -``` - -```output -data/trial-016.jpg,0.04907247340425532 -data/trial-020.jpg,0.06381366356382978 -data/trial-216.jpg,0.14205152925531914 -data/trial-293.jpg,0.13665791223404256 -``` - -::::::::::::::::::::::::::::::::::::::::: callout - -Compare your results with the values above. Do they match exactly? You may find that certain decimal values differ slightly, even when using identical input parameters. - -This variation often stems from the specific versions of your installed packages (such as `numpy` or `scikit-image`). As these libraries evolve, updates can introduce subtle changes in numerical handling, underlying algorithms, or rounding logic. This highlights why reproducible environments, as well as reproducible code, are essential for consistent scientific computing. - -::::::::::::::::::::::::::::::::::::::::: - -::::::::::::::::::::::::::::::::::::::: challenge - -## Ignoring more of the images -- brainstorming (10 min) - -Let us take a closer look at the binary masks produced by the `measure_root_mass` function. - -![](fig/four-maize-roots-binary.jpg){alt='Binary masks of the four maize root images'} - -You may have noticed in the section on automatic thresholding that -the thresholded image does include regions of the image aside of the -plant root: the numbered labels and the white circles in each image -are preserved during the thresholding, because their grayscale -values are above the threshold. -Therefore, our calculated root mass ratios include the white pixels -of the label and white circle that are not part of the plant root. -Those extra pixels affect how accurate the root mass calculation is! - -How might we remove the labels and circles before calculating the ratio, -so that our results are more accurate? -Think about some options given what we have learned so far. - -::::::::::::::: solution - -## Solution - -One approach we might take is to try to completely mask out a region -from each image, particularly, -the area containing the white circle and the numbered label. -If we had coordinates for a rectangular area on the image -that contained the circle and the label, -we could mask the area out by using techniques we learned in -[the *Drawing and Bitwise Operations* episode](04-drawing.md). - -However, a closer inspection of the binary images raises some issues with -that approach. -Since the roots are not always constrained to a certain area in the image, -and since the circles and labels are in different locations each time, -we would have difficulties coming up with a single rectangle that would -work for *every* image. -We could create a different masking rectangle for each image, -but that is not a practicable approach -if we have hundreds or thousands of images to process. - -Another approach we could take is -to apply two thresholding steps to the image. -Look at the graylevel histogram of the file `data/trial-016.jpg` shown -above again: -Notice the peak near 1.0? -Recall that a grayscale value of 1.0 corresponds to white pixels: -the peak corresponds to the white label and circle. -So, we could use simple binary thresholding to mask the white circle and -label from the image, -and then we could use Otsu's method to select the pixels in -the plant portion of the image. - -Note that most of this extra work in processing the image could have been -avoided during the experimental design stage, -with some careful consideration of how the resulting images would be used. -For example, all of the following measures could have made the images easier -to process, by helping us predict and/or detect where the label is in the image -and subsequently mask it from further processing: - -- Using labels with a consistent size and shape -- Placing all the labels in the same position, relative to the sample -- Using a non-white label, with non-black writing - -::::::::::::::::::::::::: - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -::::::::::::::::::::::::::::::::::::::: challenge - -## Ignoring more of the images -- implementation (30 min - optional, not included in timing) - -Implement an enhanced version of the function `measure_root_mass` -that applies simple binary thresholding to remove the white circle -and label from the image before applying Otsu's method. - -::::::::::::::: solution - -## Solution - -We can apply a simple binary thresholding with a threshold -`t=0.95` to remove the label and circle from the image. We can then use the -binary mask to calculate the Otsu threshold without the pixels from the label and circle. - -```python -def enhanced_root_mass(filename, sigma): - - # read the original image, converting to grayscale on the fly - image = iio.imread(uri=filename, mode="L") - - # blur before thresholding - blurred_image = ski.filters.gaussian(image, sigma=sigma) - - # perform binary thresholding to mask the white label and circle - binary_mask = blurred_image < 0.95 - - # perform automatic thresholding using only the pixels with value True in the binary mask - t = ski.filters.threshold_otsu(blurred_image[binary_mask]) - - # update binary mask to identify pixels which are both less than 0.95 and greater than t - binary_mask = (blurred_image < 0.95) & (blurred_image > t) - - # determine root mass ratio - root_pixels = np.count_nonzero(binary_mask) - density = root_pixels / binary_mask.size - - return density - - -all_files = sorted(glob.glob("data/trial-*.jpg")) -for filename in all_files: - density = enhanced_root_mass(filename=filename, sigma=1.5) - # output in format suitable for .csv - print(filename, density, sep=",") -``` - -The output of the improved program does illustrate that the white circles -and labels were skewing our root mass ratios: - -```output -data/trial-016.jpg,0.046261136968085106 -data/trial-020.jpg,0.05887167553191489 -data/trial-216.jpg,0.13712067819148935 -data/trial-293.jpg,0.1319044215425532 -``` -:::::::::::::::::::::::::::::::::::::::::: spoiler - -### What is `&` doing in the example above? - -The `&` operator above means that we have defined a logical AND statement. This combines the two tests of pixel intensities in the blurred image such that both must be true for a pixel's position to be set to `True` in the resulting mask. - -| Result of `t < blurred_image` | Result of `blurred_image < 0.95` | Resulting value in `binary_mask` | -|----------|---------|---------| -| False | True | False | -| True | False | False | -| True | True | True | - -Knowing how to construct this kind of logical operation can be very helpful in image processing. The University of Minnesota Library's [guide to Boolean operators](https://libguides.umn.edu/BooleanOperators) is a good place to start if you want to learn more. - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -Here are the binary images produced by the additional thresholding. -Note that we have not completely removed the offending white pixels. -Outlines still remain. -However, we have reduced the number of extraneous pixels, -which should make the output more accurate. - -![](fig/four-maize-roots-binary-improved.jpg){alt='Improved binary masks of the four maize root images'} - -::::::::::::::::::::::::: - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -::::::::::::::::::::::::::::::::::::::: challenge - -## Thresholding a bacteria colony image (15 min) - -In the images directory `data/`, you will find an image named `colonies-01.tif`. - -![](fig/colonies-01.jpg){alt='Image of bacteria colonies in a petri dish'} - -This is one of the images you will be working with in the -morphometric challenge at the end of the workshop. - -1. Plot and inspect the grayscale histogram of the image to - determine a good threshold value for the image. -2. Create a binary mask that leaves the pixels in the bacteria - colonies "on" while turning the rest of the pixels in the image - "off". - -::::::::::::::: solution - -## Solution - -Here is the code to create the grayscale histogram: - -```python -bacteria = iio.imread(uri="data/colonies-01.tif") -gray_image = ski.color.rgb2gray(bacteria) -blurred_image = ski.filters.gaussian(gray_image, sigma=1.0) -histogram, bin_edges = np.histogram(blurred_image, bins=256, range=(0.0, 1.0)) -fig, ax = plt.subplots() -ax.plot(bin_edges[0:-1], histogram) -ax.set_title("Graylevel histogram") -ax.set_xlabel("gray value") -ax.set_ylabel("pixel count") -ax.set_xlim(0, 1.0) -``` - -![](fig/colonies-01-histogram.png){alt='Grayscale histogram of the bacteria colonies image'} - -The peak near one corresponds to the white image background, -and the broader peak around 0.5 corresponds to the yellow/brown -culture medium in the dish. -The small peak near zero is what we are after: the dark bacteria colonies. -A reasonable choice thus might be to leave pixels below `t=0.2` on. - -Here is the code to create and show the binarized image using the -`<` operator with a threshold `t=0.2`: - -```python -t = 0.2 -binary_mask = blurred_image < t - -fig, ax = plt.subplots() -ax.imshow(binary_mask, cmap="gray") -``` - -![](fig/colonies-01-mask.png){alt='Binary mask of the bacteria colonies image'} - -When you experiment with the threshold a bit, you can see that in -particular the size of the bacteria colony near the edge of the -dish in the top right is affected by the choice of the threshold. - - - -::::::::::::::::::::::::: - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -:::::::::::::::::::::::::::::::::::::::: keypoints - -- Thresholding produces a binary image, where all pixels with intensities above (or below) a threshold value are turned on, while all other pixels are turned off. -- The binary images produced by thresholding are held in two-dimensional NumPy arrays, since they have only one colour value channel. They are boolean, hence they contain the values 0 (off) and 1 (on). -- Thresholding can be used to create masks that select only the interesting parts of an image, or as the first step before edge detection or finding contours. - -:::::::::::::::::::::::::::::::::::::::::::::::::: diff --git a/episodes/08-connected-components.md b/episodes/08-connected-components.md deleted file mode 100644 index 095204ae4..000000000 --- a/episodes/08-connected-components.md +++ /dev/null @@ -1,838 +0,0 @@ ---- -title: Connected Component Analysis -teaching: 70 -exercises: 55 ---- - -::::::::::::::::::::::::::::::::::::::: objectives - -- Understand the term object in the context of images. -- Learn about pixel connectivity. -- Learn how Connected Component Analysis (CCA) works. -- Use CCA to produce an image that highlights every object in a different colour. -- Characterise each object with numbers that describe its appearance. - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -:::::::::::::::::::::::::::::::::::::::: questions - -- How to extract separate objects from an image and describe these objects quantitatively. - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -## Objects - -In [the *Thresholding* episode](07-thresholding.md) -we have covered dividing an image into foreground and background pixels. -In the shapes example image, -we considered the coloured shapes as foreground *objects* on a white background. - -![](fig/shapes-01.jpg){alt='Original shapes image' .image-with-shadow} - -In thresholding we went from the original image to this version: - -![](fig/shapes-01-mask.png){alt='Mask created by thresholding'} - -Here, we created a mask that only highlights the parts of the image -that we find interesting, the *objects*. -All objects have pixel value of `True` while the background pixels are `False`. - -By looking at the mask image, -one can count the objects that are present in the image (7). -But how did we actually do that, -how did we decide which lump of pixels constitutes a single object? - - - -## Pixel Neighborhoods - -In order to decide which pixels belong to the same object, -one can exploit their neighborhood: -pixels that are directly next to each other -and belong to the foreground class can be considered to belong to the same object. - -Let's discuss the concept of pixel neighborhoods in more detail. -Consider the following mask "image" with 8 rows, and 8 columns. -For the purpose of illustration, the digit `0` is used to represent -background pixels, and the letter `X` is used to represent -object pixels foreground). - -```output -0 0 0 0 0 0 0 0 -0 X X 0 0 0 0 0 -0 X X 0 0 0 0 0 -0 0 0 X X X 0 0 -0 0 0 X X X X 0 -0 0 0 0 0 0 0 0 -``` - -The pixels are organised in a rectangular grid. -In order to understand pixel neighborhoods -we will introduce the concept of "jumps" between pixels. -The jumps follow two rules: -First rule is that one jump is only allowed along the column, or the row. -Diagonal jumps are not allowed. -So, from a centre pixel, denoted with `o`, -only the pixels indicated with a `1` are reachable: - -```output -- 1 - -1 o 1 -- 1 - -``` - -The pixels on the diagonal (from `o`) are not reachable with a single jump, -which is denoted by the `-`. -The pixels reachable with a single jump form the **1-jump** neighborhood. - -The second rule states that in a sequence of jumps, -one may only jump in row and column direction once -> they have to be *orthogonal*. -An example of a sequence of orthogonal jumps is shown below. -Starting from `o` the first jump goes along the row to the right. -The second jump then goes along the column direction up. -After this, -the sequence cannot be continued as a jump has already been made -in both row and column direction. - -```output -- - 2 -- o 1 -- - - -``` - -All pixels reachable with one, or two jumps form the **2-jump** neighborhood. -The grid below illustrates the pixels reachable from -the centre pixel `o` with a single jump, highlighted with a `1`, -and the pixels reachable with 2 jumps with a `2`. - -```output -2 1 2 -1 o 1 -2 1 2 -``` - -We want to revisit our example image mask from above and apply -the two different neighborhood rules. -With a single jump connectivity for each pixel, we get two resulting objects, -highlighted in the image with `A`'s and `B`'s. - -```output -0 0 0 0 0 0 0 0 -0 A A 0 0 0 0 0 -0 A A 0 0 0 0 0 -0 0 0 B B B 0 0 -0 0 0 B B B B 0 -0 0 0 0 0 0 0 0 -``` - -In the 1-jump version, -only pixels that have direct neighbors along rows or columns are considered connected. -Diagonal connections are not included in the 1-jump neighborhood. -With two jumps, however, we only get a single object `A` because pixels are also -considered connected along the diagonals. - -```output -0 0 0 0 0 0 0 0 -0 A A 0 0 0 0 0 -0 A A 0 0 0 0 0 -0 0 0 A A A 0 0 -0 0 0 A A A A 0 -0 0 0 0 0 0 0 0 -``` - -::::::::::::::::::::::::::::::::::::::: challenge - -## Object counting (optional, not included in timing) - -How many objects with 1 orthogonal jump, how many with 2 orthogonal jumps? - -```output -0 0 0 0 0 0 0 0 -0 X 0 0 0 X X 0 -0 0 X 0 0 0 0 0 -0 X 0 X X X 0 0 -0 X 0 X X 0 0 0 -0 0 0 0 0 0 0 0 -``` - -1 jump - -a) 1 -b) 5 -c) 2 - -::::::::::::::: solution - -## Solution - -b) 5 - - -::::::::::::::::::::::::: - -2 jumps - -a) 2 -b) 3 -c) 5 - -::::::::::::::: solution - -## Solution - -a) 2 - - - -::::::::::::::::::::::::: - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -::::::::::::::::::::::::::::::::::::::::: callout - -## Jumps and neighborhoods - -We have just introduced how you can reach different neighboring -pixels by performing one or more orthogonal jumps. We have used the -terms 1-jump and 2-jump neighborhood. There is also a different way -of referring to these neighborhoods: the 4- and 8-neighborhood. -With a single jump you can reach four pixels from a given starting -pixel. Hence, the 1-jump neighborhood corresponds to the -4-neighborhood. When two orthogonal jumps are allowed, eight pixels -can be reached, so the 2-jump neighborhood corresponds to the -8-neighborhood. - - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -## Connected Component Analysis - -In order to find the objects in an image, we want to employ an -operation that is called Connected Component Analysis (CCA). -This operation takes a binary image as an input. -Usually, the `False` value in this image is associated with background pixels, -and the `True` value indicates foreground, or object pixels. -Such an image can be produced, e.g., with thresholding. -Given a thresholded image, -the connected component analysis produces a new *labeled* image with integer pixel values. -Pixels with the same value, belong to the same object. -scikit-image provides connected component analysis in the function `ski.measure.label()`. -Let us add this function to the already familiar steps of thresholding an image. - -First, import the packages needed for this episode: - -```python -import imageio.v3 as iio -import ipympl -import matplotlib.pyplot as plt -import numpy as np -import skimage as ski - -%matplotlib widget -``` - -In this episode, we will use the `ski.measure.label` function to perform the CCA. - -Next, we define a reusable Python function `connected_components`: - -```python -def connected_components(filename, sigma=1.0, t=0.5, connectivity=2): - # load the image - image = iio.imread(filename) - # convert the image to grayscale - gray_image = ski.color.rgb2gray(image) - # denoise the image with a Gaussian filter - blurred_image = ski.filters.gaussian(gray_image, sigma=sigma) - # mask the image according to threshold - binary_mask = blurred_image < t - # perform connected component analysis - labeled_image, count = ski.measure.label(binary_mask, - connectivity=connectivity, return_num=True) - return labeled_image, count -``` - -The first four lines of code are familiar from -[the *Thresholding* episode](07-thresholding.md). - - - -Then we call the `ski.measure.label` function. -This function has one positional argument where we pass the `binary_mask`, -i.e., the binary image to work on. -With the optional argument `connectivity`, -we specify the neighborhood in units of orthogonal jumps. -For example, -by setting `connectivity=2` we will consider the 2-jump neighborhood introduced above. -The function returns a `labeled_image` where each pixel has -a unique value corresponding to the object it belongs to. -In addition, we pass the optional parameter `return_num=True` to return -the maximum label index as `count`. - -::::::::::::::::::::::::::::::::::::::::: callout - -## Optional parameters and return values - -The optional parameter `return_num` changes the data type that is -returned by the function `ski.measure.label`. -The number of labels is only returned if `return_num` is *True*. -Otherwise, the function only returns the labeled image. -This means that we have to pay attention when assigning -the return value to a variable. -If we omit the optional parameter `return_num` or pass `return_num=False`, -we can call the function as - -```python -labeled_image = ski.measure.label(binary_mask) -``` - -If we pass `return_num=True`, the function returns a tuple and we -can assign it as - -```python -labeled_image, count = ski.measure.label(binary_mask, return_num=True) -``` - -If we used the same assignment as in the first case, -the variable `labeled_image` would become a tuple, -in which `labeled_image[0]` is the image -and `labeled_image[1]` is the number of labels. -This could cause confusion if we assume that `labeled_image` -only contains the image and pass it to other functions. -If you get an -`AttributeError: 'tuple' object has no attribute 'shape'` -or similar, check if you have assigned the return values consistently -with the optional parameters. - - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -We can call the above function `connected_components` and -display the labeled image like so: - -```python -labeled_image, count = connected_components(filename="data/shapes-01.jpg", sigma=2.0, t=0.9, connectivity=2) - -fig, ax = plt.subplots() -ax.imshow(labeled_image) -ax.set_axis_off(); -``` - -:::::::::::::::: spoiler - -## Do you see an empty image? - -If you are using an older version of Matplotlib you might get a warning -`UserWarning: Low image data range; displaying image with stretched contrast.` -or just see a visually empty image. - -What went wrong? -When you hover over the image, -the pixel values are shown as numbers in the lower corner of the viewer. -You can see that some pixels have values different from `0`, -so they are not actually all the same value. -Let's find out more by examining `labeled_image`. -Properties that might be interesting in this context are `dtype`, -the minimum and maximum value. -We can print them with the following lines: - -```python -print("dtype:", labeled_image.dtype) -print("min:", np.min(labeled_image)) -print("max:", np.max(labeled_image)) -``` - -Examining the output can give us a clue why the image appears empty. - -```output -dtype: int32 -min: 0 -max: 11 -``` - -The `dtype` of `labeled_image` is `int32`. -This means that values in this image range from `-2 ** 31` to `2 ** 31 - 1`. -Those are really big numbers. -From this available space we only use the range from `0` to `11`. -When showing this image in the viewer, -it may squeeze the complete range into 256 gray values. -Therefore, the range of our numbers does not produce any visible variation. One way to rectify this -is to explicitly specify the data range we want the colormap to cover: - -```python -fig, ax = plt.subplots() -ax.imshow(labeled_image, vmin=np.min(labeled_image), vmax=np.max(labeled_image)) -``` - -Note this is the default behaviour for newer versions of `matplotlib.pyplot.imshow`. -Alternatively we could convert the image to RGB and then display it. - - -::::::::::::::::::::::::: - -::::::::::::::::::::::::::::::::::::::::: callout - -## Suppressing outputs in Jupyter Notebooks - -We just used `ax.set_axis_off();` to hide the axis from the image for a visually cleaner figure. The -semicolon is added to supress the output(s) of the statement, in this [case](https://matplotlib.org/stable/api/_as_gen/matplotlib.pyplot.axis.html) -the axis limits. This is specific to Jupyter Notebooks. - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -We can use the function `ski.color.label2rgb()` -to convert the 32-bit grayscale labeled image to standard RGB colour -(recall that we already used the `ski.color.rgb2gray()` function -to convert to grayscale). -With `ski.color.label2rgb()`, -all objects are coloured according to a list of colours that can be customised. -We can use the following commands to convert and show the image: - -```python -# convert the label image to color image -colored_label_image = ski.color.label2rgb(labeled_image, bg_label=0) - -fig, ax = plt.subplots() -ax.imshow(colored_label_image) -ax.set_axis_off(); -``` - -![](fig/shapes-01-labeled.png){alt='Labeled objects'} - -::::::::::::::::::::::::::::::::::::::: challenge - -## How many objects are in that image (15 min) - -Now, it is your turn to practice. -Using the function `connected_components`, -find two ways of printing out the number of objects found in the image. - -What number of objects would you expect to get? - -How does changing the `sigma` and `threshold` values influence the result? - -::::::::::::::: solution - -## Solution - -As you might have guessed, the return value `count` already -contains the number of objects found in the image. So it can simply be printed -with - -```python -print("Found", count, "objects in the image.") -``` - -But there is also a way to obtain the number of found objects from -the labeled image itself. -Recall that all pixels that belong to a single object -are assigned the same integer value. -The connected component algorithm produces consecutive numbers. -The background gets the value `0`, -the first object gets the value `1`, -the second object the value `2`, and so on. -This means that by finding the object with the maximum value, -we also know how many objects there are in the image. -We can thus use the `np.max` function from NumPy to -find the maximum value that equals the number of found objects: - -```python -num_objects = np.max(labeled_image) -print("Found", num_objects, "objects in the image.") -``` - -Invoking the function with `sigma=2.0`, and `threshold=0.9`, -both methods will print - -```output -Found 11 objects in the image. -``` - -Lowering the threshold will result in fewer objects. -The higher the threshold is set, the more objects are found. -More and more background noise gets picked up as objects. -Larger sigmas produce binary masks with less noise and hence -a smaller number of objects. -Setting sigma too high bears the danger of merging objects. - - - -::::::::::::::::::::::::: - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -You might wonder why the connected component analysis with `sigma=2.0`, -and `threshold=0.9` finds 11 objects, whereas we would expect only 7 objects. -Where are the four additional objects? -With a bit of detective work, we can spot some small objects in the image, -for example, near the left border. - -![](fig/shapes-01-cca-detail.png){alt='shapes-01.jpg mask detail'} - -For us it is clear that these small spots are artifacts and -not objects we are interested in. -But how can we tell the computer? -One way to calibrate the algorithm is to adjust the parameters for -blurring (`sigma`) and thresholding (`t`), -but you may have noticed during the above exercise that -it is quite hard to find a combination that produces the right output number. -In some cases, background noise gets picked up as an object. -And with other parameters, -some of the foreground objects get broken up or disappear completely. -Therefore, we need other criteria to describe desired properties of the objects -that are found. - -## Morphometrics - Describe object features with numbers - -Morphometrics is concerned with the quantitative analysis of objects and -considers properties such as size and shape. -For the example of the images with the shapes, -our intuition tells us that the objects should be of a certain size or area. -So we could use a minimum area as a criterion for when an object should be detected. -To apply such a criterion, -we need a way to calculate the area of objects found by connected components. -Recall how we determined the root mass in -[the *Thresholding* episode](07-thresholding.md) -by counting the pixels in the binary mask. -But here we want to calculate the area of several objects in the labeled image. -The scikit-image library provides the function `ski.measure.regionprops` -to measure the properties of labeled regions. -It returns a list of `RegionProperties` that describe each connected region in the images. -The properties can be accessed using the attributes of the `RegionProperties` data type. -Here we will use the properties `"area"` and `"label"`. -You can explore the scikit-image documentation to learn about other properties available. - -We can get a list of areas of the labeled objects as follows: - -```python -# compute object features and extract object areas -object_features = ski.measure.regionprops(labeled_image) -object_areas = [objf["area"] for objf in object_features] -object_areas -``` - -This will produce the output - -```output -[318542, 1, 523204, 496613, 517331, 143, 256215, 1, 68, 338784, 265755] -``` - -::::::::::::::::::::::::::::::::::::::: challenge - -## Plot a histogram of the object area distribution (10 min) - -Similar to how we determined a "good" threshold in -[the *Thresholding* episode](07-thresholding.md), -it is often helpful to inspect the histogram of an object property. -For example, we want to look at the distribution of the object areas. - -1. Create and examine a [histogram](05-creating-histograms.md) - of the object areas obtained with `ski.measure.regionprops`. -2. What does the histogram tell you about the objects? - -::::::::::::::: solution - -## Solution - -The histogram can be plotted with - -```python -fig, ax = plt.subplots() -ax.hist(object_areas) -ax.set_xlabel("Area (pixels)") -ax.set_ylabel("Number of objects"); -``` - -![](fig/shapes-01-areas-histogram.png){alt='Histogram of object areas'} - -The histogram shows the number of objects (vertical axis) -whose area is within a certain range (horizontal axis). -The height of the bars in the histogram indicates -the prevalence of objects with a certain area. -The whole histogram tells us about the distribution of object sizes in the image. -It is often possible to identify gaps between groups of bars -(or peaks if we draw the histogram as a continuous curve) -that tell us about certain groups in the image. - -In this example, we can see that there are four small objects that -contain less than 50000 pixels. -Then there is a group of four (1+1+2) objects in -the range between 200000 and 400000, -and three objects with a size around 500000. -For our object count, we might want to disregard the small objects as artifacts, -i.e, we want to ignore the leftmost bar of the histogram. -We could use a threshold of 50000 as the minimum area to count. -In fact, the `object_areas` list already tells us that -there are fewer than 200 pixels in these objects. -Therefore, it is reasonable to require a minimum area of at least 200 pixels -for a detected object. -In practice, finding the "right" threshold can be tricky and -usually involves an educated guess based on domain knowledge. - - - -::::::::::::::::::::::::: - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -::::::::::::::::::::::::::::::::::::::: challenge - -## Filter objects by area (10 min) - -Now we would like to use a minimum area criterion to obtain a more -accurate count of the objects in the image. - -1. Find a way to calculate the number of objects by only counting - objects above a certain area. - -::::::::::::::: solution - -## Solution - -One way to count only objects above a certain area is to first -create a list of those objects, and then take the length of that -list as the object count. This can be done as follows: - -```python -min_area = 200 -large_objects = [] -for objf in object_features: - if objf["area"] > min_area: - large_objects.append(objf["label"]) -print("Found", len(large_objects), "objects!") -``` - -Another option is to use NumPy arrays to create the list of large objects. -We first create an array `object_areas` containing the object areas, -and an array `object_labels` containing the object labels. -The labels of the objects are also returned by `ski.measure.regionprops`. -We have already seen that we can create boolean arrays using comparison operators. -Here we can use `object_areas > min_area` -to produce an array that has the same dimension as `object_labels`. -It can then be used to select the labels of objects whose area is -greater than `min_area` by indexing: - -```python -object_areas = np.array([objf["area"] for objf in object_features]) -object_labels = np.array([objf["label"] for objf in object_features]) -large_objects = object_labels[object_areas > min_area] -print("Found", len(large_objects), "objects!") -``` - -The advantage of using NumPy arrays is that -`for` loops and `if` statements in Python can be slow, -and in practice the first approach may not be feasible -if the image contains a large number of objects. -In that case, NumPy array functions turn out to be very useful because -they are much faster. - -In this example, we can also use the `np.count_nonzero` function -that we have seen earlier together with the `>` operator to count -the objects whose area is above `min_area`. - -```python -n = np.count_nonzero(object_areas > min_area) -print("Found", n, "objects!") -``` - -For all three alternatives, the output is the same and gives the -expected count of 7 objects. - - - -::::::::::::::::::::::::: - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -::::::::::::::::::::::::::::::::::::::::: callout - -## Using functions from NumPy and other Python packages - -Functions from Python packages such as NumPy are often more efficient and -require less code to write. -It is a good idea to browse the reference pages of `numpy` and `skimage` to -look for an availabe function that can solve a given task. - - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -::::::::::::::::::::::::::::::::::::::: challenge - -## Remove small objects (20 min) - -We might also want to exclude (mask) the small objects when plotting -the labeled image. - -2. Enhance the `connected_components` function such that - it automatically removes objects that are below a certain area that is - passed to the function as an optional parameter. - -::::::::::::::: solution - -## Solution - -To remove the small objects from the labeled image, -we change the value of all pixels that belong to the small objects to -the background label 0. -One way to do this is to loop over all objects and -set the pixels that match the label of the object to 0. - -```python -for object_id, objf in enumerate(object_features, start=1): - if objf["area"] < min_area: - labeled_image[labeled_image == objf["label"]] = 0 -``` - -Here NumPy functions can also be used to eliminate -`for` loops and `if` statements. -Like above, we can create an array of the small object labels with -the comparison `object_areas < min_area`. -We can use another NumPy function, `np.isin`, -to set the pixels of all small objects to 0. -`np.isin` takes two arrays and returns a boolean array with values -`True` if the entry of the first array is found in the second array, -and `False` otherwise. -This array can then be used to index the `labeled_image` and -set the entries that belong to small objects to `0`. - -```python -object_areas = np.array([objf["area"] for objf in object_features]) -object_labels = np.array([objf["label"] for objf in object_features]) -small_objects = object_labels[object_areas < min_area] -labeled_image[np.isin(labeled_image, small_objects)] = 0 -``` - -An even more elegant way to remove small objects from the image is -to leverage the `ski.morphology` module. -It provides a function `ski.morphology.remove_small_objects` that -does exactly what we are looking for. -It can be applied to a binary image and -returns a mask in which all objects smaller than `min_area` are excluded, -i.e, their pixel values are set to `False`. -We can then apply `ski.measure.label` to the masked image: - -```python -object_mask = ski.morphology.remove_small_objects(binary_mask, min_size=min_area) -labeled_image, n = ski.measure.label(object_mask, - connectivity=connectivity, return_num=True) -``` - -Using the scikit-image features, we can implement -the `enhanced_connected_component` as follows: - -```python -def enhanced_connected_components(filename, sigma=1.0, t=0.5, connectivity=2, min_area=0): - image = iio.imread(filename) - gray_image = ski.color.rgb2gray(image) - blurred_image = ski.filters.gaussian(gray_image, sigma=sigma) - binary_mask = blurred_image < t - object_mask = ski.morphology.remove_small_objects(binary_mask, min_size=min_area) - labeled_image, count = ski.measure.label(object_mask, - connectivity=connectivity, return_num=True) - return labeled_image, count -``` - -We can now call the function with a chosen `min_area` and -display the resulting labeled image: - -```python -labeled_image, count = enhanced_connected_components(filename="data/shapes-01.jpg", sigma=2.0, t=0.9, - connectivity=2, min_area=min_area) -colored_label_image = ski.color.label2rgb(labeled_image, bg_label=0) - -fig, ax = plt.subplots() -ax.imshow(colored_label_image) -ax.set_axis_off(); - -print("Found", count, "objects in the image.") -``` - -![](fig/shapes-01-filtered-objects.png){alt='Objects filtered by area'} - -```output -Found 7 objects in the image. -``` - -Note that the small objects are "gone" and we obtain the correct -number of 7 objects in the image. - - - -::::::::::::::::::::::::: - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -::::::::::::::::::::::::::::::::::::::: challenge - -## Colour objects by area (optional, not included in timing) - -Finally, we would like to display the image with the objects coloured -according to the magnitude of their area. -In practice, this can be used with other properties to give -visual cues of the object properties. - -::::::::::::::: solution - -## Solution - -We already know how to get the areas of the objects from the `regionprops`. -We just need to insert a zero area value for the background -(to colour it like a zero size object). -The background is also labeled `0` in the `labeled_image`, -so we insert the zero area value in front of the first element of -`object_areas` with `np.insert`. -Then we can create a `colored_area_image` where we assign each pixel value -the area by indexing the `object_areas` with the label values in `labeled_image`. - -```python -object_areas = np.array([objf["area"] for objf in ski.measure.regionprops(labeled_image)]) -# prepend zero to object_areas array for background pixels -object_areas = np.insert(0, obj=1, values=object_areas) -# create image where the pixels in each object are equal to that object's area -colored_area_image = object_areas[labeled_image] - -fig, ax = plt.subplots() -im = ax.imshow(colored_area_image) -cbar = fig.colorbar(im, ax=ax, shrink=0.85) -cbar.ax.set_title("Area") -ax.set_axis_off(); -``` - -![](fig/shapes-01-objects-coloured-by-area.png){alt='Objects colored by area'} - -::::::::::::::::::::::::::::::::::::::::: callout - -You may have noticed that in the solution, we have used the -`labeled_image` to index the array `object_areas`. This is an -example of [advanced indexing in -NumPy](https://numpy.org/doc/stable/user/basics.indexing.html#advanced-indexing) -The result is an array of the same shape as the `labeled_image` -whose pixel values are selected from `object_areas` according to -the object label. Hence the objects will be colored by area when -the result is displayed. Note that advanced indexing with an -integer array works slightly different than the indexing with a -Boolean array that we have used for masking. While Boolean array -indexing returns only the entries corresponding to the `True` -values of the index, integer array indexing returns an array -with the same shape as the index. You can read more about advanced -indexing in the [NumPy -documentation](https://numpy.org/doc/stable/user/basics.indexing.html#advanced-indexing). - - - - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -::::::::::::::::::::::::: - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -:::::::::::::::::::::::::::::::::::::::: keypoints - -- We can use `ski.measure.label` to find and label connected objects in an image. -- We can use `ski.measure.regionprops` to measure properties of labeled objects. -- We can use `ski.morphology.remove_small_objects` to mask small objects and remove artifacts from an image. -- We can display the labeled image to view the objects coloured by label. - -:::::::::::::::::::::::::::::::::::::::::::::::::: diff --git a/episodes/09-challenges.md b/episodes/09-challenges.md deleted file mode 100644 index 26496d499..000000000 --- a/episodes/09-challenges.md +++ /dev/null @@ -1,257 +0,0 @@ ---- -title: Capstone Challenge -teaching: 10 -exercises: 40 ---- - -::::::::::::::::::::::::::::::::::::::: objectives - -- Bring together everything you've learnt so far to count bacterial colonies in 3 images. - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -:::::::::::::::::::::::::::::::::::::::: questions - -- How can we automatically count bacterial colonies with image analysis? - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -In this episode, we will provide a final challenge for you to attempt, -based on all the skills you have acquired so far. -This challenge will be related to the shape of objects in images (*morphometrics*). - -## Morphometrics: Bacteria Colony Counting - -As mentioned in [the workshop introduction](01-introduction.md), -your morphometric challenge is to determine how many bacteria colonies are in -each of these images: - -![](fig/colonies-01.jpg){alt='Colony image 1'} - -![](fig/colonies-02.jpg){alt='Colony image 2'} - -![](fig/colonies-03.jpg){alt='Colony image 3'} - -The image files can be found at -`data/colonies-01.tif`, -`data/colonies-02.tif`, -and `data/colonies-03.tif`. - -::::::::::::::::::::::::::::::::::::::: challenge - -## Morphometrics for bacterial colonies - -Write a Python program that uses scikit-image to -count the number of bacteria colonies in each image, -and for each, produce a new image that highlights the colonies. -The image should look similar to this one: - -![](fig/colonies-01-summary.png){alt='Sample morphometric output'} - -Additionally, print out the number of colonies for each image. - -Use what you have learnt about [histograms](05-creating-histograms.md), -[thresholding](07-thresholding.md) and -[connected component analysis](08-connected-components.md). -Try to put your code into a re-usable function, -so that it can be applied conveniently to any image file. - -::::::::::::::: solution - -## Solution - -First, let's work through the process for one image: - -```python -import imageio.v3 as iio -import ipympl -import matplotlib.pyplot as plt -import numpy as np -import skimage as ski - -%matplotlib widget - -bacteria_image = iio.imread(uri="data/colonies-01.tif") - -# display the image -fig, ax = plt.subplots() -ax.imshow(bacteria_image) -``` - -![](fig/colonies-01.jpg){alt='Colony image 1'} - -Next, we need to threshold the image to create a mask that covers only -the dark bacterial colonies. -This is easier using a grayscale image, so we convert it here: - -```python -gray_bacteria = ski.color.rgb2gray(bacteria_image) - -# display the gray image -fig, ax = plt.subplots() -ax.imshow(gray_bacteria, cmap="gray") -``` - -![](fig/colonies-01-gray.png){alt='Gray Colonies'} - -Next, we blur the image and create a histogram: - -```python -blurred_image = ski.filters.gaussian(gray_bacteria, sigma=1.0) -histogram, bin_edges = np.histogram(blurred_image, bins=256, range=(0.0, 1.0)) -fig, ax = plt.subplots() -ax.plot(bin_edges[0:-1], histogram) -ax.set_title("Graylevel histogram") -ax.set_xlabel("gray value") -ax.set_ylabel("pixel count") -ax.set_xlim(0, 1.0) -``` - -![](fig/colonies-01-histogram.png){alt='Histogram image'} - -In this histogram, we see three peaks - -the left one (i.e. the darkest pixels) is our colonies, -the central peak is the yellow/brown culture medium in the dish, -and the right one (i.e. the brightest pixels) is the white image background. -Therefore, we choose a threshold that selects the small left peak: - -```python -mask = blurred_image < 0.2 -fig, ax = plt.subplots() -ax.imshow(mask, cmap="gray") -``` - -![](fig/colonies-01-mask.png){alt='Colony mask image'} - -This mask shows us where the colonies are in the image - -but how can we count how many there are? -This requires connected component analysis: - -```python -labeled_image, count = ski.measure.label(mask, return_num=True) -print(count) -``` - -Finally, we create the summary image of the coloured colonies on top of -the grayscale image: - -```python -# color each of the colonies a different color -colored_label_image = ski.color.label2rgb(labeled_image, bg_label=0) -# give our grayscale image rgb channels, so we can add the colored colonies -summary_image = ski.color.gray2rgb(gray_bacteria) -summary_image[mask] = colored_label_image[mask] - -# plot overlay -fig, ax = plt.subplots() -ax.imshow(summary_image) -``` - -![](fig/colonies-01-summary.png){alt='Sample morphometric output'} - -Now that we've completed the task for one image, -we need to repeat this for the remaining two images. -This is a good point to collect the lines above into a re-usable function: - -```python -def count_colonies(image_filename): - bacteria_image = iio.imread(image_filename) - gray_bacteria = ski.color.rgb2gray(bacteria_image) - blurred_image = ski.filters.gaussian(gray_bacteria, sigma=1.0) - mask = blurred_image < 0.2 - labeled_image, count = ski.measure.label(mask, return_num=True) - print(f"There are {count} colonies in {image_filename}") - - colored_label_image = ski.color.label2rgb(labeled_image, bg_label=0) - summary_image = ski.color.gray2rgb(gray_bacteria) - summary_image[mask] = colored_label_image[mask] - fig, ax = plt.subplots() - ax.imshow(summary_image) -``` - -Now we can do this analysis on all the images via a for loop: - -```python -for image_filename in ["data/colonies-01.tif", "data/colonies-02.tif", "data/colonies-03.tif"]: - count_colonies(image_filename=image_filename) -``` - -![](fig/colonies-01-summary.png){alt='Colony 1 output'} -![](fig/colonies-02-summary.png){alt='Colony 2 output'} -![](fig/colonies-03-summary.png){alt='Colony 3 output'} - -You'll notice that for the images with more colonies, the results aren't perfect. -For example, some small colonies are missing, -and there are likely some small black spots being labelled incorrectly as colonies. -You could expand this solution to, for example, -use an automatically determined threshold for each image, -which may fit each better. -Also, you could filter out colonies below a certain size -(as we did in [the *Connected Component Analysis* episode](08-connected-components.md)). -You'll also see that some touching colonies are merged into one big colony. -This could be fixed with more complicated segmentation methods -(outside of the scope of this lesson) like -[watershed](https://scikit-image.org/docs/dev/auto_examples/segmentation/plot_watershed.html). - -::::::::::::::::::::::::: - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -::::::::::::::::::::::::::::::::::::::: challenge - -## Colony counting with minimum size and automated threshold (optional, not included in timing) - -Modify your function from the previous exercise for colony counting to (i) exclude objects smaller -than a specified size and (ii) use an automated thresholding approach, e.g. Otsu, to mask the -colonies. - -::::::::::::::::::::::::::::::::::::::: solution - -Here is a modified function with the requested features. Note when calculating the Otsu threshold we -don't include the very bright pixels outside the dish. - -```python -def count_colonies_enhanced(image_filename, sigma=1.0, min_colony_size=10, connectivity=2): - - bacteria_image = iio.imread(image_filename) - gray_bacteria = ski.color.rgb2gray(bacteria_image) - blurred_image = ski.filters.gaussian(gray_bacteria, sigma=sigma) - - # create mask excluding the very bright pixels outside the dish - # we dont want to include these when calculating the automated threshold - mask = blurred_image < 0.90 - # calculate an automated threshold value within the dish using the Otsu method - t = ski.filters.threshold_otsu(blurred_image[mask]) - # update mask to select pixels both within the dish and less than t - mask = np.logical_and(mask, blurred_image < t) - # remove objects smaller than specified area - mask = ski.morphology.remove_small_objects(mask, min_size=min_colony_size) - - labeled_image, count = ski.measure.label(mask, return_num=True) - print(f"There are {count} colonies in {image_filename}") - colored_label_image = ski.color.label2rgb(labeled_image, bg_label=0) - summary_image = ski.color.gray2rgb(gray_bacteria) - summary_image[mask] = colored_label_image[mask] - fig, ax = plt.subplots() - ax.imshow(summary_image) -``` - -::::::::::::::::::::::::: - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -:::::::::::::::::::::::::::::::::::::::: keypoints - -- Using thresholding, connected component analysis and other tools we can automatically segment images of bacterial colonies. -- These methods are useful for many scientific problems, especially those involving morphometrics. - -:::::::::::::::::::::::::::::::::::::::::::::::::: - - -:::::::::::::::::::::::::::::::::::::::: discussion - -## Where to go from here? - -Take a look at our [curated list of resources](further-reading.md) for further publicly available courses, resources and scientific literature around image processing and more. - -:::::::::::::::::::::::::::::::::::::::::::::::::: \ No newline at end of file diff --git a/favicon-16x16.png b/favicon-16x16.png new file mode 100644 index 000000000..d44f8acb4 Binary files /dev/null and b/favicon-16x16.png differ diff --git a/favicon-32x32.png b/favicon-32x32.png new file mode 100644 index 000000000..63441d4c3 Binary files /dev/null and b/favicon-32x32.png differ diff --git a/favicons/cp/apple-touch-icon-114x114.png b/favicons/cp/apple-touch-icon-114x114.png new file mode 100644 index 000000000..a60b75810 Binary files /dev/null and b/favicons/cp/apple-touch-icon-114x114.png differ diff --git a/favicons/cp/apple-touch-icon-120x120.png b/favicons/cp/apple-touch-icon-120x120.png new file mode 100644 index 000000000..8f20a8f12 Binary files /dev/null and b/favicons/cp/apple-touch-icon-120x120.png differ diff --git a/favicons/cp/apple-touch-icon-144x144.png b/favicons/cp/apple-touch-icon-144x144.png new file mode 100644 index 000000000..4be151b14 Binary files /dev/null and b/favicons/cp/apple-touch-icon-144x144.png differ diff --git a/favicons/cp/apple-touch-icon-152x152.png b/favicons/cp/apple-touch-icon-152x152.png new file mode 100644 index 000000000..7d1d94395 Binary files /dev/null and b/favicons/cp/apple-touch-icon-152x152.png differ diff --git a/favicons/cp/apple-touch-icon-57x57.png b/favicons/cp/apple-touch-icon-57x57.png new file mode 100644 index 000000000..92309cef2 Binary files /dev/null and b/favicons/cp/apple-touch-icon-57x57.png differ diff --git a/favicons/cp/apple-touch-icon-60x60.png b/favicons/cp/apple-touch-icon-60x60.png new file mode 100644 index 000000000..de8148e58 Binary files /dev/null and b/favicons/cp/apple-touch-icon-60x60.png differ diff --git a/favicons/cp/apple-touch-icon-72x72.png b/favicons/cp/apple-touch-icon-72x72.png new file mode 100644 index 000000000..81d7e3d83 Binary files /dev/null and b/favicons/cp/apple-touch-icon-72x72.png differ diff --git a/favicons/cp/apple-touch-icon-76x76.png b/favicons/cp/apple-touch-icon-76x76.png new file mode 100644 index 000000000..15bca5c77 Binary files /dev/null and b/favicons/cp/apple-touch-icon-76x76.png differ diff --git a/favicons/cp/favicon-128.png b/favicons/cp/favicon-128.png new file mode 100644 index 000000000..e612cdc15 Binary files /dev/null and b/favicons/cp/favicon-128.png differ diff --git a/favicons/cp/favicon-16x16.png b/favicons/cp/favicon-16x16.png new file mode 100644 index 000000000..65b331112 Binary files /dev/null and b/favicons/cp/favicon-16x16.png differ diff --git a/favicons/cp/favicon-196x196.png b/favicons/cp/favicon-196x196.png new file mode 100644 index 000000000..0da938b27 Binary files /dev/null and b/favicons/cp/favicon-196x196.png differ diff --git a/favicons/cp/favicon-32x32.png b/favicons/cp/favicon-32x32.png new file mode 100644 index 000000000..0c1442e39 Binary files /dev/null and b/favicons/cp/favicon-32x32.png differ diff --git a/favicons/cp/favicon-96x96.png b/favicons/cp/favicon-96x96.png new file mode 100644 index 000000000..bed74ec8d Binary files /dev/null and b/favicons/cp/favicon-96x96.png differ diff --git a/favicons/cp/favicon.ico b/favicons/cp/favicon.ico new file mode 100644 index 000000000..4f2f2f11f Binary files /dev/null and b/favicons/cp/favicon.ico differ diff --git a/favicons/cp/mstile-144x144.png b/favicons/cp/mstile-144x144.png new file mode 100644 index 000000000..4be151b14 Binary files /dev/null and b/favicons/cp/mstile-144x144.png differ diff --git a/favicons/cp/mstile-150x150.png b/favicons/cp/mstile-150x150.png new file mode 100644 index 000000000..bf7ad5e79 Binary files /dev/null and b/favicons/cp/mstile-150x150.png differ diff --git a/favicons/cp/mstile-310x150.png b/favicons/cp/mstile-310x150.png new file mode 100644 index 000000000..6ac804843 Binary files /dev/null and b/favicons/cp/mstile-310x150.png differ diff --git a/favicons/cp/mstile-310x310.png b/favicons/cp/mstile-310x310.png new file mode 100644 index 000000000..b77814750 Binary files /dev/null and b/favicons/cp/mstile-310x310.png differ diff --git a/favicons/cp/mstile-70x70.png b/favicons/cp/mstile-70x70.png new file mode 100644 index 000000000..e612cdc15 Binary files /dev/null and b/favicons/cp/mstile-70x70.png differ diff --git a/favicons/dc/apple-touch-icon-114x114.png b/favicons/dc/apple-touch-icon-114x114.png new file mode 100644 index 000000000..edafbda13 Binary files /dev/null and b/favicons/dc/apple-touch-icon-114x114.png differ diff --git a/favicons/dc/apple-touch-icon-120x120.png b/favicons/dc/apple-touch-icon-120x120.png new file mode 100644 index 000000000..ee145ec5c Binary files /dev/null and b/favicons/dc/apple-touch-icon-120x120.png differ diff --git a/favicons/dc/apple-touch-icon-144x144.png b/favicons/dc/apple-touch-icon-144x144.png new file mode 100644 index 000000000..bf5070144 Binary files /dev/null and b/favicons/dc/apple-touch-icon-144x144.png differ diff --git a/favicons/dc/apple-touch-icon-152x152.png b/favicons/dc/apple-touch-icon-152x152.png new file mode 100644 index 000000000..bd596c816 Binary files /dev/null and b/favicons/dc/apple-touch-icon-152x152.png differ diff --git a/favicons/dc/apple-touch-icon-57x57.png b/favicons/dc/apple-touch-icon-57x57.png new file mode 100644 index 000000000..61c152735 Binary files /dev/null and b/favicons/dc/apple-touch-icon-57x57.png differ diff --git a/favicons/dc/apple-touch-icon-60x60.png b/favicons/dc/apple-touch-icon-60x60.png new file mode 100644 index 000000000..9daad3633 Binary files /dev/null and b/favicons/dc/apple-touch-icon-60x60.png differ diff --git a/favicons/dc/apple-touch-icon-72x72.png b/favicons/dc/apple-touch-icon-72x72.png new file mode 100644 index 000000000..2069520fc Binary files /dev/null and b/favicons/dc/apple-touch-icon-72x72.png differ diff --git a/favicons/dc/apple-touch-icon-76x76.png b/favicons/dc/apple-touch-icon-76x76.png new file mode 100644 index 000000000..3db01ca7d Binary files /dev/null and b/favicons/dc/apple-touch-icon-76x76.png differ diff --git a/favicons/dc/favicon-128.png b/favicons/dc/favicon-128.png new file mode 100644 index 000000000..9e3de2a49 Binary files /dev/null and b/favicons/dc/favicon-128.png differ diff --git a/favicons/dc/favicon-16x16.png b/favicons/dc/favicon-16x16.png new file mode 100644 index 000000000..4c9f9b8c5 Binary files /dev/null and b/favicons/dc/favicon-16x16.png differ diff --git a/favicons/dc/favicon-196x196.png b/favicons/dc/favicon-196x196.png new file mode 100644 index 000000000..588afc213 Binary files /dev/null and b/favicons/dc/favicon-196x196.png differ diff --git a/favicons/dc/favicon-32x32.png b/favicons/dc/favicon-32x32.png new file mode 100644 index 000000000..9c2ecbfbe Binary files /dev/null and b/favicons/dc/favicon-32x32.png differ diff --git a/favicons/dc/favicon-96x96.png b/favicons/dc/favicon-96x96.png new file mode 100644 index 000000000..ff13fc06e Binary files /dev/null and b/favicons/dc/favicon-96x96.png differ diff --git a/favicons/dc/favicon.ico b/favicons/dc/favicon.ico new file mode 100644 index 000000000..e4715f329 Binary files /dev/null and b/favicons/dc/favicon.ico differ diff --git a/favicons/dc/mstile-144x144.png b/favicons/dc/mstile-144x144.png new file mode 100644 index 000000000..bf5070144 Binary files /dev/null and b/favicons/dc/mstile-144x144.png differ diff --git a/favicons/dc/mstile-150x150.png b/favicons/dc/mstile-150x150.png new file mode 100644 index 000000000..c5844cca3 Binary files /dev/null and b/favicons/dc/mstile-150x150.png differ diff --git a/favicons/dc/mstile-310x150.png b/favicons/dc/mstile-310x150.png new file mode 100644 index 000000000..786813af8 Binary files /dev/null and b/favicons/dc/mstile-310x150.png differ diff --git a/favicons/dc/mstile-310x310.png b/favicons/dc/mstile-310x310.png new file mode 100644 index 000000000..9580653c6 Binary files /dev/null and b/favicons/dc/mstile-310x310.png differ diff --git a/favicons/dc/mstile-70x70.png b/favicons/dc/mstile-70x70.png new file mode 100644 index 000000000..9e3de2a49 Binary files /dev/null and b/favicons/dc/mstile-70x70.png differ diff --git a/favicons/lc/apple-touch-icon-114x114.png b/favicons/lc/apple-touch-icon-114x114.png new file mode 100644 index 000000000..6c83127ca Binary files /dev/null and b/favicons/lc/apple-touch-icon-114x114.png differ diff --git a/favicons/lc/apple-touch-icon-120x120.png b/favicons/lc/apple-touch-icon-120x120.png new file mode 100644 index 000000000..8334648f1 Binary files /dev/null and b/favicons/lc/apple-touch-icon-120x120.png differ diff --git a/favicons/lc/apple-touch-icon-144x144.png b/favicons/lc/apple-touch-icon-144x144.png new file mode 100644 index 000000000..5f32151ed Binary files /dev/null and b/favicons/lc/apple-touch-icon-144x144.png differ diff --git a/favicons/lc/apple-touch-icon-152x152.png b/favicons/lc/apple-touch-icon-152x152.png new file mode 100644 index 000000000..4e5c177ce Binary files /dev/null and b/favicons/lc/apple-touch-icon-152x152.png differ diff --git a/favicons/lc/apple-touch-icon-57x57.png b/favicons/lc/apple-touch-icon-57x57.png new file mode 100644 index 000000000..61f9c9c74 Binary files /dev/null and b/favicons/lc/apple-touch-icon-57x57.png differ diff --git a/favicons/lc/apple-touch-icon-60x60.png b/favicons/lc/apple-touch-icon-60x60.png new file mode 100644 index 000000000..ccb5ada1c Binary files /dev/null and b/favicons/lc/apple-touch-icon-60x60.png differ diff --git a/favicons/lc/apple-touch-icon-72x72.png b/favicons/lc/apple-touch-icon-72x72.png new file mode 100644 index 000000000..517d459af Binary files /dev/null and b/favicons/lc/apple-touch-icon-72x72.png differ diff --git a/favicons/lc/apple-touch-icon-76x76.png b/favicons/lc/apple-touch-icon-76x76.png new file mode 100644 index 000000000..17454b311 Binary files /dev/null and b/favicons/lc/apple-touch-icon-76x76.png differ diff --git a/favicons/lc/favicon-128.png b/favicons/lc/favicon-128.png new file mode 100644 index 000000000..9d781c901 Binary files /dev/null and b/favicons/lc/favicon-128.png differ diff --git a/favicons/lc/favicon-16x16.png b/favicons/lc/favicon-16x16.png new file mode 100644 index 000000000..3c20abcc0 Binary files /dev/null and b/favicons/lc/favicon-16x16.png differ diff --git a/favicons/lc/favicon-196x196.png b/favicons/lc/favicon-196x196.png new file mode 100644 index 000000000..46baaf8f9 Binary files /dev/null and b/favicons/lc/favicon-196x196.png differ diff --git a/favicons/lc/favicon-32x32.png b/favicons/lc/favicon-32x32.png new file mode 100644 index 000000000..ed6701ea1 Binary files /dev/null and b/favicons/lc/favicon-32x32.png differ diff --git a/favicons/lc/favicon-96x96.png b/favicons/lc/favicon-96x96.png new file mode 100644 index 000000000..bc468c73a Binary files /dev/null and b/favicons/lc/favicon-96x96.png differ diff --git a/favicons/lc/favicon.ico b/favicons/lc/favicon.ico new file mode 100644 index 000000000..5c14e8091 Binary files /dev/null and b/favicons/lc/favicon.ico differ diff --git a/favicons/lc/mstile-144x144.png b/favicons/lc/mstile-144x144.png new file mode 100644 index 000000000..5f32151ed Binary files /dev/null and b/favicons/lc/mstile-144x144.png differ diff --git a/favicons/lc/mstile-150x150.png b/favicons/lc/mstile-150x150.png new file mode 100644 index 000000000..924953a84 Binary files /dev/null and b/favicons/lc/mstile-150x150.png differ diff --git a/favicons/lc/mstile-310x150.png b/favicons/lc/mstile-310x150.png new file mode 100644 index 000000000..e4dcda444 Binary files /dev/null and b/favicons/lc/mstile-310x150.png differ diff --git a/favicons/lc/mstile-310x310.png b/favicons/lc/mstile-310x310.png new file mode 100644 index 000000000..a12c87632 Binary files /dev/null and b/favicons/lc/mstile-310x310.png differ diff --git a/favicons/lc/mstile-70x70.png b/favicons/lc/mstile-70x70.png new file mode 100644 index 000000000..9d781c901 Binary files /dev/null and b/favicons/lc/mstile-70x70.png differ diff --git a/favicons/swc/apple-touch-icon-114x114.png b/favicons/swc/apple-touch-icon-114x114.png new file mode 100644 index 000000000..e5125f8c4 Binary files /dev/null and b/favicons/swc/apple-touch-icon-114x114.png differ diff --git a/favicons/swc/apple-touch-icon-120x120.png b/favicons/swc/apple-touch-icon-120x120.png new file mode 100644 index 000000000..0f97a0aec Binary files /dev/null and b/favicons/swc/apple-touch-icon-120x120.png differ diff --git a/favicons/swc/apple-touch-icon-144x144.png b/favicons/swc/apple-touch-icon-144x144.png new file mode 100644 index 000000000..7441446cc Binary files /dev/null and b/favicons/swc/apple-touch-icon-144x144.png differ diff --git a/favicons/swc/apple-touch-icon-152x152.png b/favicons/swc/apple-touch-icon-152x152.png new file mode 100644 index 000000000..45cc338e5 Binary files /dev/null and b/favicons/swc/apple-touch-icon-152x152.png differ diff --git a/favicons/swc/apple-touch-icon-57x57.png b/favicons/swc/apple-touch-icon-57x57.png new file mode 100644 index 000000000..e180a4a32 Binary files /dev/null and b/favicons/swc/apple-touch-icon-57x57.png differ diff --git a/favicons/swc/apple-touch-icon-60x60.png b/favicons/swc/apple-touch-icon-60x60.png new file mode 100644 index 000000000..c96fd6ce7 Binary files /dev/null and b/favicons/swc/apple-touch-icon-60x60.png differ diff --git a/favicons/swc/apple-touch-icon-72x72.png b/favicons/swc/apple-touch-icon-72x72.png new file mode 100644 index 000000000..aae014aa7 Binary files /dev/null and b/favicons/swc/apple-touch-icon-72x72.png differ diff --git a/favicons/swc/apple-touch-icon-76x76.png b/favicons/swc/apple-touch-icon-76x76.png new file mode 100644 index 000000000..2167f94a7 Binary files /dev/null and b/favicons/swc/apple-touch-icon-76x76.png differ diff --git a/favicons/swc/favicon-128.png b/favicons/swc/favicon-128.png new file mode 100644 index 000000000..f61df620c Binary files /dev/null and b/favicons/swc/favicon-128.png differ diff --git a/favicons/swc/favicon-16x16.png b/favicons/swc/favicon-16x16.png new file mode 100644 index 000000000..2d20a4061 Binary files /dev/null and b/favicons/swc/favicon-16x16.png differ diff --git a/favicons/swc/favicon-196x196.png b/favicons/swc/favicon-196x196.png new file mode 100644 index 000000000..2a20d3a6f Binary files /dev/null and b/favicons/swc/favicon-196x196.png differ diff --git a/favicons/swc/favicon-32x32.png b/favicons/swc/favicon-32x32.png new file mode 100644 index 000000000..f622b73a1 Binary files /dev/null and b/favicons/swc/favicon-32x32.png differ diff --git a/favicons/swc/favicon-96x96.png b/favicons/swc/favicon-96x96.png new file mode 100644 index 000000000..5e57f66a5 Binary files /dev/null and b/favicons/swc/favicon-96x96.png differ diff --git a/favicons/swc/favicon.ico b/favicons/swc/favicon.ico new file mode 100644 index 000000000..f771790f2 Binary files /dev/null and b/favicons/swc/favicon.ico differ diff --git a/favicons/swc/mstile-144x144.png b/favicons/swc/mstile-144x144.png new file mode 100644 index 000000000..7441446cc Binary files /dev/null and b/favicons/swc/mstile-144x144.png differ diff --git a/favicons/swc/mstile-150x150.png b/favicons/swc/mstile-150x150.png new file mode 100644 index 000000000..d1594bcb8 Binary files /dev/null and b/favicons/swc/mstile-150x150.png differ diff --git a/favicons/swc/mstile-310x150.png b/favicons/swc/mstile-310x150.png new file mode 100644 index 000000000..f7d58b2b9 Binary files /dev/null and b/favicons/swc/mstile-310x150.png differ diff --git a/favicons/swc/mstile-310x310.png b/favicons/swc/mstile-310x310.png new file mode 100644 index 000000000..b632b421c Binary files /dev/null and b/favicons/swc/mstile-310x310.png differ diff --git a/favicons/swc/mstile-70x70.png b/favicons/swc/mstile-70x70.png new file mode 100644 index 000000000..f61df620c Binary files /dev/null and b/favicons/swc/mstile-70x70.png differ diff --git a/episodes/fig/3D_petri_after_blurring-dark.png b/fig/3D_petri_after_blurring-dark.png similarity index 100% rename from episodes/fig/3D_petri_after_blurring-dark.png rename to fig/3D_petri_after_blurring-dark.png diff --git a/episodes/fig/3D_petri_after_blurring.png b/fig/3D_petri_after_blurring.png similarity index 100% rename from episodes/fig/3D_petri_after_blurring.png rename to fig/3D_petri_after_blurring.png diff --git a/episodes/fig/3D_petri_before_blurring-dark.png b/fig/3D_petri_before_blurring-dark.png similarity index 100% rename from episodes/fig/3D_petri_before_blurring-dark.png rename to fig/3D_petri_before_blurring-dark.png diff --git a/episodes/fig/3D_petri_before_blurring.png b/fig/3D_petri_before_blurring.png similarity index 100% rename from episodes/fig/3D_petri_before_blurring.png rename to fig/3D_petri_before_blurring.png diff --git a/episodes/fig/Gaussian_2D-dark.png b/fig/Gaussian_2D-dark.png similarity index 100% rename from episodes/fig/Gaussian_2D-dark.png rename to fig/Gaussian_2D-dark.png diff --git a/episodes/fig/Gaussian_2D.png b/fig/Gaussian_2D.png similarity index 100% rename from episodes/fig/Gaussian_2D.png rename to fig/Gaussian_2D.png diff --git a/episodes/fig/Normal_Distribution_PDF.svg b/fig/Normal_Distribution_PDF.svg similarity index 100% rename from episodes/fig/Normal_Distribution_PDF.svg rename to fig/Normal_Distribution_PDF.svg diff --git a/episodes/fig/beads-canny-ui-dark.png b/fig/beads-canny-ui-dark.png similarity index 100% rename from episodes/fig/beads-canny-ui-dark.png rename to fig/beads-canny-ui-dark.png diff --git a/episodes/fig/beads-canny-ui.png b/fig/beads-canny-ui.png similarity index 100% rename from episodes/fig/beads-canny-ui.png rename to fig/beads-canny-ui.png diff --git a/episodes/fig/beads-dark.jpg b/fig/beads-dark.jpg similarity index 100% rename from episodes/fig/beads-dark.jpg rename to fig/beads-dark.jpg diff --git a/episodes/fig/beads-out-dark.png b/fig/beads-out-dark.png similarity index 100% rename from episodes/fig/beads-out-dark.png rename to fig/beads-out-dark.png diff --git a/episodes/fig/beads-out.png b/fig/beads-out.png similarity index 100% rename from episodes/fig/beads-out.png rename to fig/beads-out.png diff --git a/episodes/fig/beads.jpg b/fig/beads.jpg similarity index 100% rename from episodes/fig/beads.jpg rename to fig/beads.jpg diff --git a/episodes/fig/black-and-white-dark.jpg b/fig/black-and-white-dark.jpg similarity index 100% rename from episodes/fig/black-and-white-dark.jpg rename to fig/black-and-white-dark.jpg diff --git a/episodes/fig/black-and-white-edge-pixels-dark.jpg b/fig/black-and-white-edge-pixels-dark.jpg similarity index 100% rename from episodes/fig/black-and-white-edge-pixels-dark.jpg rename to fig/black-and-white-edge-pixels-dark.jpg diff --git a/episodes/fig/black-and-white-edge-pixels.jpg b/fig/black-and-white-edge-pixels.jpg similarity index 100% rename from episodes/fig/black-and-white-edge-pixels.jpg rename to fig/black-and-white-edge-pixels.jpg diff --git a/episodes/fig/black-and-white-gradient-dark.png b/fig/black-and-white-gradient-dark.png similarity index 100% rename from episodes/fig/black-and-white-gradient-dark.png rename to fig/black-and-white-gradient-dark.png diff --git a/episodes/fig/black-and-white-gradient.png b/fig/black-and-white-gradient.png similarity index 100% rename from episodes/fig/black-and-white-gradient.png rename to fig/black-and-white-gradient.png diff --git a/episodes/fig/black-and-white.jpg b/fig/black-and-white.jpg similarity index 100% rename from episodes/fig/black-and-white.jpg rename to fig/black-and-white.jpg diff --git a/episodes/fig/blur-demo-dark.gif b/fig/blur-demo-dark.gif similarity index 100% rename from episodes/fig/blur-demo-dark.gif rename to fig/blur-demo-dark.gif diff --git a/episodes/fig/blur-demo.gif b/fig/blur-demo.gif similarity index 100% rename from episodes/fig/blur-demo.gif rename to fig/blur-demo.gif diff --git a/episodes/fig/board-coordinates-dark.jpg b/fig/board-coordinates-dark.jpg similarity index 100% rename from episodes/fig/board-coordinates-dark.jpg rename to fig/board-coordinates-dark.jpg diff --git a/episodes/fig/board-coordinates.jpg b/fig/board-coordinates.jpg similarity index 100% rename from episodes/fig/board-coordinates.jpg rename to fig/board-coordinates.jpg diff --git a/episodes/fig/board-dark.jpg b/fig/board-dark.jpg similarity index 100% rename from episodes/fig/board-dark.jpg rename to fig/board-dark.jpg diff --git a/episodes/fig/board-final-dark.jpg b/fig/board-final-dark.jpg similarity index 100% rename from episodes/fig/board-final-dark.jpg rename to fig/board-final-dark.jpg diff --git a/episodes/fig/board-final.jpg b/fig/board-final.jpg similarity index 100% rename from episodes/fig/board-final.jpg rename to fig/board-final.jpg diff --git a/episodes/fig/board.jpg b/fig/board.jpg similarity index 100% rename from episodes/fig/board.jpg rename to fig/board.jpg diff --git a/episodes/fig/cartesian-coordinates-dark.png b/fig/cartesian-coordinates-dark.png similarity index 100% rename from episodes/fig/cartesian-coordinates-dark.png rename to fig/cartesian-coordinates-dark.png diff --git a/episodes/fig/cartesian-coordinates.png b/fig/cartesian-coordinates.png similarity index 100% rename from episodes/fig/cartesian-coordinates.png rename to fig/cartesian-coordinates.png diff --git a/episodes/fig/cat-corner-blue-dark.png b/fig/cat-corner-blue-dark.png similarity index 100% rename from episodes/fig/cat-corner-blue-dark.png rename to fig/cat-corner-blue-dark.png diff --git a/episodes/fig/cat-corner-blue.png b/fig/cat-corner-blue.png similarity index 100% rename from episodes/fig/cat-corner-blue.png rename to fig/cat-corner-blue.png diff --git a/episodes/fig/cat-dark.jpg b/fig/cat-dark.jpg similarity index 100% rename from episodes/fig/cat-dark.jpg rename to fig/cat-dark.jpg diff --git a/episodes/fig/cat-eye-pixels-dark.jpg b/fig/cat-eye-pixels-dark.jpg similarity index 100% rename from episodes/fig/cat-eye-pixels-dark.jpg rename to fig/cat-eye-pixels-dark.jpg diff --git a/episodes/fig/cat-eye-pixels.jpg b/fig/cat-eye-pixels.jpg similarity index 100% rename from episodes/fig/cat-eye-pixels.jpg rename to fig/cat-eye-pixels.jpg diff --git a/episodes/fig/cat.jpg b/fig/cat.jpg similarity index 100% rename from episodes/fig/cat.jpg rename to fig/cat.jpg diff --git a/fig/chair-layers-rgb.png b/fig/chair-layers-rgb.png new file mode 100644 index 000000000..cdf531425 Binary files /dev/null and b/fig/chair-layers-rgb.png differ diff --git a/fig/chair-original.jpg b/fig/chair-original.jpg new file mode 100644 index 000000000..8977bd71e Binary files /dev/null and b/fig/chair-original.jpg differ diff --git a/episodes/fig/checkerboard-blue-channel-dark.png b/fig/checkerboard-blue-channel-dark.png similarity index 100% rename from episodes/fig/checkerboard-blue-channel-dark.png rename to fig/checkerboard-blue-channel-dark.png diff --git a/episodes/fig/checkerboard-blue-channel.png b/fig/checkerboard-blue-channel.png similarity index 100% rename from episodes/fig/checkerboard-blue-channel.png rename to fig/checkerboard-blue-channel.png diff --git a/episodes/fig/checkerboard-dark.png b/fig/checkerboard-dark.png similarity index 100% rename from episodes/fig/checkerboard-dark.png rename to fig/checkerboard-dark.png diff --git a/episodes/fig/checkerboard-green-channel-dark.png b/fig/checkerboard-green-channel-dark.png similarity index 100% rename from episodes/fig/checkerboard-green-channel-dark.png rename to fig/checkerboard-green-channel-dark.png diff --git a/episodes/fig/checkerboard-green-channel.png b/fig/checkerboard-green-channel.png similarity index 100% rename from episodes/fig/checkerboard-green-channel.png rename to fig/checkerboard-green-channel.png diff --git a/episodes/fig/checkerboard-red-channel-dark.png b/fig/checkerboard-red-channel-dark.png similarity index 100% rename from episodes/fig/checkerboard-red-channel-dark.png rename to fig/checkerboard-red-channel-dark.png diff --git a/episodes/fig/checkerboard-red-channel.png b/fig/checkerboard-red-channel.png similarity index 100% rename from episodes/fig/checkerboard-red-channel.png rename to fig/checkerboard-red-channel.png diff --git a/episodes/fig/checkerboard.png b/fig/checkerboard.png similarity index 100% rename from episodes/fig/checkerboard.png rename to fig/checkerboard.png diff --git a/episodes/fig/colonies-01-dark.jpg b/fig/colonies-01-dark.jpg similarity index 100% rename from episodes/fig/colonies-01-dark.jpg rename to fig/colonies-01-dark.jpg diff --git a/episodes/fig/colonies-01-gray-dark.png b/fig/colonies-01-gray-dark.png similarity index 100% rename from episodes/fig/colonies-01-gray-dark.png rename to fig/colonies-01-gray-dark.png diff --git a/episodes/fig/colonies-01-gray.png b/fig/colonies-01-gray.png similarity index 100% rename from episodes/fig/colonies-01-gray.png rename to fig/colonies-01-gray.png diff --git a/episodes/fig/colonies-01-histogram.png b/fig/colonies-01-histogram.png similarity index 100% rename from episodes/fig/colonies-01-histogram.png rename to fig/colonies-01-histogram.png diff --git a/episodes/fig/colonies-01-mask-dark.png b/fig/colonies-01-mask-dark.png similarity index 100% rename from episodes/fig/colonies-01-mask-dark.png rename to fig/colonies-01-mask-dark.png diff --git a/episodes/fig/colonies-01-mask.png b/fig/colonies-01-mask.png similarity index 100% rename from episodes/fig/colonies-01-mask.png rename to fig/colonies-01-mask.png diff --git a/episodes/fig/colonies-01-summary-dark.png b/fig/colonies-01-summary-dark.png similarity index 100% rename from episodes/fig/colonies-01-summary-dark.png rename to fig/colonies-01-summary-dark.png diff --git a/episodes/fig/colonies-01-summary.png b/fig/colonies-01-summary.png similarity index 100% rename from episodes/fig/colonies-01-summary.png rename to fig/colonies-01-summary.png diff --git a/episodes/fig/colonies-01.jpg b/fig/colonies-01.jpg similarity index 100% rename from episodes/fig/colonies-01.jpg rename to fig/colonies-01.jpg diff --git a/episodes/fig/colonies-02-dark.jpg b/fig/colonies-02-dark.jpg similarity index 100% rename from episodes/fig/colonies-02-dark.jpg rename to fig/colonies-02-dark.jpg diff --git a/episodes/fig/colonies-02-summary-dark.png b/fig/colonies-02-summary-dark.png similarity index 100% rename from episodes/fig/colonies-02-summary-dark.png rename to fig/colonies-02-summary-dark.png diff --git a/episodes/fig/colonies-02-summary.png b/fig/colonies-02-summary.png similarity index 100% rename from episodes/fig/colonies-02-summary.png rename to fig/colonies-02-summary.png diff --git a/episodes/fig/colonies-02.jpg b/fig/colonies-02.jpg similarity index 100% rename from episodes/fig/colonies-02.jpg rename to fig/colonies-02.jpg diff --git a/episodes/fig/colonies-03-dark.jpg b/fig/colonies-03-dark.jpg similarity index 100% rename from episodes/fig/colonies-03-dark.jpg rename to fig/colonies-03-dark.jpg diff --git a/episodes/fig/colonies-03-summary-dark.png b/fig/colonies-03-summary-dark.png similarity index 100% rename from episodes/fig/colonies-03-summary-dark.png rename to fig/colonies-03-summary-dark.png diff --git a/episodes/fig/colonies-03-summary.png b/fig/colonies-03-summary.png similarity index 100% rename from episodes/fig/colonies-03-summary.png rename to fig/colonies-03-summary.png diff --git a/episodes/fig/colonies-03.jpg b/fig/colonies-03.jpg similarity index 100% rename from episodes/fig/colonies-03.jpg rename to fig/colonies-03.jpg diff --git a/fig/colonies01.png b/fig/colonies01.png new file mode 100644 index 000000000..21bf6c582 Binary files /dev/null and b/fig/colonies01.png differ diff --git a/episodes/fig/colony-mask-dark.png b/fig/colony-mask-dark.png similarity index 100% rename from episodes/fig/colony-mask-dark.png rename to fig/colony-mask-dark.png diff --git a/episodes/fig/colony-mask.png b/fig/colony-mask.png similarity index 100% rename from episodes/fig/colony-mask.png rename to fig/colony-mask.png diff --git a/episodes/fig/colour-table-dark.png b/fig/colour-table-dark.png similarity index 100% rename from episodes/fig/colour-table-dark.png rename to fig/colour-table-dark.png diff --git a/episodes/fig/colour-table.png b/fig/colour-table.png similarity index 100% rename from episodes/fig/colour-table.png rename to fig/colour-table.png diff --git a/episodes/fig/combination-dark.png b/fig/combination-dark.png similarity index 100% rename from episodes/fig/combination-dark.png rename to fig/combination-dark.png diff --git a/episodes/fig/combination.png b/fig/combination.png similarity index 100% rename from episodes/fig/combination.png rename to fig/combination.png diff --git a/episodes/fig/drawing-practice-dark.jpg b/fig/drawing-practice-dark.jpg similarity index 100% rename from episodes/fig/drawing-practice-dark.jpg rename to fig/drawing-practice-dark.jpg diff --git a/episodes/fig/drawing-practice.jpg b/fig/drawing-practice.jpg similarity index 100% rename from episodes/fig/drawing-practice.jpg rename to fig/drawing-practice.jpg diff --git a/episodes/fig/eight-dark.png b/fig/eight-dark.png similarity index 100% rename from episodes/fig/eight-dark.png rename to fig/eight-dark.png diff --git a/episodes/fig/eight.png b/fig/eight.png similarity index 100% rename from episodes/fig/eight.png rename to fig/eight.png diff --git a/episodes/fig/five-dark.png b/fig/five-dark.png similarity index 100% rename from episodes/fig/five-dark.png rename to fig/five-dark.png diff --git a/episodes/fig/five.png b/fig/five.png similarity index 100% rename from episodes/fig/five.png rename to fig/five.png diff --git a/episodes/fig/four-maize-roots-binary-dark.jpg b/fig/four-maize-roots-binary-dark.jpg similarity index 100% rename from episodes/fig/four-maize-roots-binary-dark.jpg rename to fig/four-maize-roots-binary-dark.jpg diff --git a/episodes/fig/four-maize-roots-binary-improved-dark.jpg b/fig/four-maize-roots-binary-improved-dark.jpg similarity index 100% rename from episodes/fig/four-maize-roots-binary-improved-dark.jpg rename to fig/four-maize-roots-binary-improved-dark.jpg diff --git a/episodes/fig/four-maize-roots-binary-improved.jpg b/fig/four-maize-roots-binary-improved.jpg similarity index 100% rename from episodes/fig/four-maize-roots-binary-improved.jpg rename to fig/four-maize-roots-binary-improved.jpg diff --git a/episodes/fig/four-maize-roots-binary.jpg b/fig/four-maize-roots-binary.jpg similarity index 100% rename from episodes/fig/four-maize-roots-binary.jpg rename to fig/four-maize-roots-binary.jpg diff --git a/episodes/fig/four-maize-roots-dark.jpg b/fig/four-maize-roots-dark.jpg similarity index 100% rename from episodes/fig/four-maize-roots-dark.jpg rename to fig/four-maize-roots-dark.jpg diff --git a/episodes/fig/four-maize-roots.jpg b/fig/four-maize-roots.jpg similarity index 100% rename from episodes/fig/four-maize-roots.jpg rename to fig/four-maize-roots.jpg diff --git a/episodes/fig/gaussian-blurred-dark.png b/fig/gaussian-blurred-dark.png similarity index 100% rename from episodes/fig/gaussian-blurred-dark.png rename to fig/gaussian-blurred-dark.png diff --git a/episodes/fig/gaussian-blurred.png b/fig/gaussian-blurred.png similarity index 100% rename from episodes/fig/gaussian-blurred.png rename to fig/gaussian-blurred.png diff --git a/episodes/fig/gaussian-kernel-dark.png b/fig/gaussian-kernel-dark.png similarity index 100% rename from episodes/fig/gaussian-kernel-dark.png rename to fig/gaussian-kernel-dark.png diff --git a/episodes/fig/gaussian-kernel.png b/fig/gaussian-kernel.png similarity index 100% rename from episodes/fig/gaussian-kernel.png rename to fig/gaussian-kernel.png diff --git a/episodes/fig/gaussian-original-dark.png b/fig/gaussian-original-dark.png similarity index 100% rename from episodes/fig/gaussian-original-dark.png rename to fig/gaussian-original-dark.png diff --git a/episodes/fig/gaussian-original.png b/fig/gaussian-original.png similarity index 100% rename from episodes/fig/gaussian-original.png rename to fig/gaussian-original.png diff --git a/episodes/fig/grayscale-dark.png b/fig/grayscale-dark.png similarity index 100% rename from episodes/fig/grayscale-dark.png rename to fig/grayscale-dark.png diff --git a/episodes/fig/grayscale.png b/fig/grayscale.png similarity index 100% rename from episodes/fig/grayscale.png rename to fig/grayscale.png diff --git a/episodes/fig/image-coordinates-dark.png b/fig/image-coordinates-dark.png similarity index 100% rename from episodes/fig/image-coordinates-dark.png rename to fig/image-coordinates-dark.png diff --git a/episodes/fig/image-coordinates.png b/fig/image-coordinates.png similarity index 100% rename from episodes/fig/image-coordinates.png rename to fig/image-coordinates.png diff --git a/episodes/fig/jupyter_overview.png b/fig/jupyter_overview.png similarity index 100% rename from episodes/fig/jupyter_overview.png rename to fig/jupyter_overview.png diff --git a/episodes/fig/left-hand-coordinates-dark.png b/fig/left-hand-coordinates-dark.png similarity index 100% rename from episodes/fig/left-hand-coordinates-dark.png rename to fig/left-hand-coordinates-dark.png diff --git a/episodes/fig/left-hand-coordinates.png b/fig/left-hand-coordinates.png similarity index 100% rename from episodes/fig/left-hand-coordinates.png rename to fig/left-hand-coordinates.png diff --git a/episodes/fig/maize-root-cluster-dark.jpg b/fig/maize-root-cluster-dark.jpg similarity index 100% rename from episodes/fig/maize-root-cluster-dark.jpg rename to fig/maize-root-cluster-dark.jpg diff --git a/episodes/fig/maize-root-cluster-histogram.png b/fig/maize-root-cluster-histogram.png similarity index 100% rename from episodes/fig/maize-root-cluster-histogram.png rename to fig/maize-root-cluster-histogram.png diff --git a/episodes/fig/maize-root-cluster-mask-dark.png b/fig/maize-root-cluster-mask-dark.png similarity index 100% rename from episodes/fig/maize-root-cluster-mask-dark.png rename to fig/maize-root-cluster-mask-dark.png diff --git a/episodes/fig/maize-root-cluster-mask.png b/fig/maize-root-cluster-mask.png similarity index 100% rename from episodes/fig/maize-root-cluster-mask.png rename to fig/maize-root-cluster-mask.png diff --git a/episodes/fig/maize-root-cluster-selected-dark.png b/fig/maize-root-cluster-selected-dark.png similarity index 100% rename from episodes/fig/maize-root-cluster-selected-dark.png rename to fig/maize-root-cluster-selected-dark.png diff --git a/episodes/fig/maize-root-cluster-selected.png b/fig/maize-root-cluster-selected.png similarity index 100% rename from episodes/fig/maize-root-cluster-selected.png rename to fig/maize-root-cluster-selected.png diff --git a/episodes/fig/maize-root-cluster-threshold-dark.jpg b/fig/maize-root-cluster-threshold-dark.jpg similarity index 100% rename from episodes/fig/maize-root-cluster-threshold-dark.jpg rename to fig/maize-root-cluster-threshold-dark.jpg diff --git a/episodes/fig/maize-root-cluster-threshold.jpg b/fig/maize-root-cluster-threshold.jpg similarity index 100% rename from episodes/fig/maize-root-cluster-threshold.jpg rename to fig/maize-root-cluster-threshold.jpg diff --git a/episodes/fig/maize-root-cluster.jpg b/fig/maize-root-cluster.jpg similarity index 100% rename from episodes/fig/maize-root-cluster.jpg rename to fig/maize-root-cluster.jpg diff --git a/episodes/fig/maize-roots-grayscale-dark.jpg b/fig/maize-roots-grayscale-dark.jpg similarity index 100% rename from episodes/fig/maize-roots-grayscale-dark.jpg rename to fig/maize-roots-grayscale-dark.jpg diff --git a/episodes/fig/maize-roots-grayscale.jpg b/fig/maize-roots-grayscale.jpg similarity index 100% rename from episodes/fig/maize-roots-grayscale.jpg rename to fig/maize-roots-grayscale.jpg diff --git a/episodes/fig/maize-roots-threshold-dark.png b/fig/maize-roots-threshold-dark.png similarity index 100% rename from episodes/fig/maize-roots-threshold-dark.png rename to fig/maize-roots-threshold-dark.png diff --git a/episodes/fig/maize-roots-threshold.png b/fig/maize-roots-threshold.png similarity index 100% rename from episodes/fig/maize-roots-threshold.png rename to fig/maize-roots-threshold.png diff --git a/episodes/fig/maize-seedling-enlarged-dark.jpg b/fig/maize-seedling-enlarged-dark.jpg similarity index 100% rename from episodes/fig/maize-seedling-enlarged-dark.jpg rename to fig/maize-seedling-enlarged-dark.jpg diff --git a/episodes/fig/maize-seedling-enlarged.jpg b/fig/maize-seedling-enlarged.jpg similarity index 100% rename from episodes/fig/maize-seedling-enlarged.jpg rename to fig/maize-seedling-enlarged.jpg diff --git a/episodes/fig/maize-seedling-original-dark.jpg b/fig/maize-seedling-original-dark.jpg similarity index 100% rename from episodes/fig/maize-seedling-original-dark.jpg rename to fig/maize-seedling-original-dark.jpg diff --git a/episodes/fig/maize-seedling-original.jpg b/fig/maize-seedling-original.jpg similarity index 100% rename from episodes/fig/maize-seedling-original.jpg rename to fig/maize-seedling-original.jpg diff --git a/episodes/fig/maize-seedlings-dark.jpg b/fig/maize-seedlings-dark.jpg similarity index 100% rename from episodes/fig/maize-seedlings-dark.jpg rename to fig/maize-seedlings-dark.jpg diff --git a/episodes/fig/maize-seedlings-mask-dark.png b/fig/maize-seedlings-mask-dark.png similarity index 100% rename from episodes/fig/maize-seedlings-mask-dark.png rename to fig/maize-seedlings-mask-dark.png diff --git a/episodes/fig/maize-seedlings-mask.png b/fig/maize-seedlings-mask.png similarity index 100% rename from episodes/fig/maize-seedlings-mask.png rename to fig/maize-seedlings-mask.png diff --git a/episodes/fig/maize-seedlings-masked-dark.jpg b/fig/maize-seedlings-masked-dark.jpg similarity index 100% rename from episodes/fig/maize-seedlings-masked-dark.jpg rename to fig/maize-seedlings-masked-dark.jpg diff --git a/episodes/fig/maize-seedlings-masked.jpg b/fig/maize-seedlings-masked.jpg similarity index 100% rename from episodes/fig/maize-seedlings-masked.jpg rename to fig/maize-seedlings-masked.jpg diff --git a/episodes/fig/maize-seedlings.jpg b/fig/maize-seedlings.jpg similarity index 100% rename from episodes/fig/maize-seedlings.jpg rename to fig/maize-seedlings.jpg diff --git a/episodes/fig/petri-blurred-intensities-plot-dark.png b/fig/petri-blurred-intensities-plot-dark.png similarity index 100% rename from episodes/fig/petri-blurred-intensities-plot-dark.png rename to fig/petri-blurred-intensities-plot-dark.png diff --git a/episodes/fig/petri-blurred-intensities-plot.png b/fig/petri-blurred-intensities-plot.png similarity index 100% rename from episodes/fig/petri-blurred-intensities-plot.png rename to fig/petri-blurred-intensities-plot.png diff --git a/episodes/fig/petri-dish-dark.png b/fig/petri-dish-dark.png similarity index 100% rename from episodes/fig/petri-dish-dark.png rename to fig/petri-dish-dark.png diff --git a/episodes/fig/petri-dish.png b/fig/petri-dish.png similarity index 100% rename from episodes/fig/petri-dish.png rename to fig/petri-dish.png diff --git a/episodes/fig/petri-original-intensities-plot-dark.png b/fig/petri-original-intensities-plot-dark.png similarity index 100% rename from episodes/fig/petri-original-intensities-plot-dark.png rename to fig/petri-original-intensities-plot-dark.png diff --git a/episodes/fig/petri-original-intensities-plot.png b/fig/petri-original-intensities-plot.png similarity index 100% rename from episodes/fig/petri-original-intensities-plot.png rename to fig/petri-original-intensities-plot.png diff --git a/episodes/fig/petri-selected-pixels-marker-dark.png b/fig/petri-selected-pixels-marker-dark.png similarity index 100% rename from episodes/fig/petri-selected-pixels-marker-dark.png rename to fig/petri-selected-pixels-marker-dark.png diff --git a/episodes/fig/petri-selected-pixels-marker.png b/fig/petri-selected-pixels-marker.png similarity index 100% rename from episodes/fig/petri-selected-pixels-marker.png rename to fig/petri-selected-pixels-marker.png diff --git a/episodes/fig/plant-seedling-colour-histogram-dark.png b/fig/plant-seedling-colour-histogram-dark.png similarity index 100% rename from episodes/fig/plant-seedling-colour-histogram-dark.png rename to fig/plant-seedling-colour-histogram-dark.png diff --git a/episodes/fig/plant-seedling-colour-histogram.png b/fig/plant-seedling-colour-histogram.png similarity index 100% rename from episodes/fig/plant-seedling-colour-histogram.png rename to fig/plant-seedling-colour-histogram.png diff --git a/episodes/fig/plant-seedling-dark.jpg b/fig/plant-seedling-dark.jpg similarity index 100% rename from episodes/fig/plant-seedling-dark.jpg rename to fig/plant-seedling-dark.jpg diff --git a/episodes/fig/plant-seedling-grayscale-dark.png b/fig/plant-seedling-grayscale-dark.png similarity index 100% rename from episodes/fig/plant-seedling-grayscale-dark.png rename to fig/plant-seedling-grayscale-dark.png diff --git a/episodes/fig/plant-seedling-grayscale-histogram-dark.png b/fig/plant-seedling-grayscale-histogram-dark.png similarity index 100% rename from episodes/fig/plant-seedling-grayscale-histogram-dark.png rename to fig/plant-seedling-grayscale-histogram-dark.png diff --git a/episodes/fig/plant-seedling-grayscale-histogram-mask-dark.png b/fig/plant-seedling-grayscale-histogram-mask-dark.png similarity index 100% rename from episodes/fig/plant-seedling-grayscale-histogram-mask-dark.png rename to fig/plant-seedling-grayscale-histogram-mask-dark.png diff --git a/episodes/fig/plant-seedling-grayscale-histogram-mask.png b/fig/plant-seedling-grayscale-histogram-mask.png similarity index 100% rename from episodes/fig/plant-seedling-grayscale-histogram-mask.png rename to fig/plant-seedling-grayscale-histogram-mask.png diff --git a/episodes/fig/plant-seedling-grayscale-histogram.png b/fig/plant-seedling-grayscale-histogram.png similarity index 100% rename from episodes/fig/plant-seedling-grayscale-histogram.png rename to fig/plant-seedling-grayscale-histogram.png diff --git a/episodes/fig/plant-seedling-grayscale.png b/fig/plant-seedling-grayscale.png similarity index 100% rename from episodes/fig/plant-seedling-grayscale.png rename to fig/plant-seedling-grayscale.png diff --git a/episodes/fig/plant-seedling.jpg b/fig/plant-seedling.jpg similarity index 100% rename from episodes/fig/plant-seedling.jpg rename to fig/plant-seedling.jpg diff --git a/episodes/fig/quality-histogram-dark.jpg b/fig/quality-histogram-dark.jpg similarity index 100% rename from episodes/fig/quality-histogram-dark.jpg rename to fig/quality-histogram-dark.jpg diff --git a/episodes/fig/quality-histogram.jpg b/fig/quality-histogram.jpg similarity index 100% rename from episodes/fig/quality-histogram.jpg rename to fig/quality-histogram.jpg diff --git a/episodes/fig/quality-jpg-dark.jpg b/fig/quality-jpg-dark.jpg similarity index 100% rename from episodes/fig/quality-jpg-dark.jpg rename to fig/quality-jpg-dark.jpg diff --git a/episodes/fig/quality-jpg.jpg b/fig/quality-jpg.jpg similarity index 100% rename from episodes/fig/quality-jpg.jpg rename to fig/quality-jpg.jpg diff --git a/episodes/fig/quality-original-dark.jpg b/fig/quality-original-dark.jpg similarity index 100% rename from episodes/fig/quality-original-dark.jpg rename to fig/quality-original-dark.jpg diff --git a/episodes/fig/quality-original.jpg b/fig/quality-original.jpg similarity index 100% rename from episodes/fig/quality-original.jpg rename to fig/quality-original.jpg diff --git a/episodes/fig/quality-tif-dark.jpg b/fig/quality-tif-dark.jpg similarity index 100% rename from episodes/fig/quality-tif-dark.jpg rename to fig/quality-tif-dark.jpg diff --git a/episodes/fig/quality-tif.jpg b/fig/quality-tif.jpg similarity index 100% rename from episodes/fig/quality-tif.jpg rename to fig/quality-tif.jpg diff --git a/episodes/fig/rectangle-gaussian-blurred-dark.png b/fig/rectangle-gaussian-blurred-dark.png similarity index 100% rename from episodes/fig/rectangle-gaussian-blurred-dark.png rename to fig/rectangle-gaussian-blurred-dark.png diff --git a/episodes/fig/rectangle-gaussian-blurred.png b/fig/rectangle-gaussian-blurred.png similarity index 100% rename from episodes/fig/rectangle-gaussian-blurred.png rename to fig/rectangle-gaussian-blurred.png diff --git a/episodes/fig/remote-control-dark.jpg b/fig/remote-control-dark.jpg similarity index 100% rename from episodes/fig/remote-control-dark.jpg rename to fig/remote-control-dark.jpg diff --git a/episodes/fig/remote-control-masked-dark.jpg b/fig/remote-control-masked-dark.jpg similarity index 100% rename from episodes/fig/remote-control-masked-dark.jpg rename to fig/remote-control-masked-dark.jpg diff --git a/episodes/fig/remote-control-masked.jpg b/fig/remote-control-masked.jpg similarity index 100% rename from episodes/fig/remote-control-masked.jpg rename to fig/remote-control-masked.jpg diff --git a/episodes/fig/remote-control.jpg b/fig/remote-control.jpg similarity index 100% rename from episodes/fig/remote-control.jpg rename to fig/remote-control.jpg diff --git a/episodes/fig/shapes-01-areas-histogram.png b/fig/shapes-01-areas-histogram.png similarity index 100% rename from episodes/fig/shapes-01-areas-histogram.png rename to fig/shapes-01-areas-histogram.png diff --git a/episodes/fig/shapes-01-canny-edge-output-dark.png b/fig/shapes-01-canny-edge-output-dark.png similarity index 100% rename from episodes/fig/shapes-01-canny-edge-output-dark.png rename to fig/shapes-01-canny-edge-output-dark.png diff --git a/episodes/fig/shapes-01-canny-edge-output.png b/fig/shapes-01-canny-edge-output.png similarity index 100% rename from episodes/fig/shapes-01-canny-edge-output.png rename to fig/shapes-01-canny-edge-output.png diff --git a/episodes/fig/shapes-01-canny-edges-dark.png b/fig/shapes-01-canny-edges-dark.png similarity index 100% rename from episodes/fig/shapes-01-canny-edges-dark.png rename to fig/shapes-01-canny-edges-dark.png diff --git a/episodes/fig/shapes-01-canny-edges.png b/fig/shapes-01-canny-edges.png similarity index 100% rename from episodes/fig/shapes-01-canny-edges.png rename to fig/shapes-01-canny-edges.png diff --git a/episodes/fig/shapes-01-canny-track-edges-dark.png b/fig/shapes-01-canny-track-edges-dark.png similarity index 100% rename from episodes/fig/shapes-01-canny-track-edges-dark.png rename to fig/shapes-01-canny-track-edges-dark.png diff --git a/episodes/fig/shapes-01-canny-track-edges.png b/fig/shapes-01-canny-track-edges.png similarity index 100% rename from episodes/fig/shapes-01-canny-track-edges.png rename to fig/shapes-01-canny-track-edges.png diff --git a/episodes/fig/shapes-01-cca-detail-dark.png b/fig/shapes-01-cca-detail-dark.png similarity index 100% rename from episodes/fig/shapes-01-cca-detail-dark.png rename to fig/shapes-01-cca-detail-dark.png diff --git a/episodes/fig/shapes-01-cca-detail.png b/fig/shapes-01-cca-detail.png similarity index 100% rename from episodes/fig/shapes-01-cca-detail.png rename to fig/shapes-01-cca-detail.png diff --git a/episodes/fig/shapes-01-dark.jpg b/fig/shapes-01-dark.jpg similarity index 100% rename from episodes/fig/shapes-01-dark.jpg rename to fig/shapes-01-dark.jpg diff --git a/episodes/fig/shapes-01-filtered-objects-dark.png b/fig/shapes-01-filtered-objects-dark.png similarity index 100% rename from episodes/fig/shapes-01-filtered-objects-dark.png rename to fig/shapes-01-filtered-objects-dark.png diff --git a/episodes/fig/shapes-01-filtered-objects.png b/fig/shapes-01-filtered-objects.png similarity index 100% rename from episodes/fig/shapes-01-filtered-objects.png rename to fig/shapes-01-filtered-objects.png diff --git a/episodes/fig/shapes-01-grayscale-dark.png b/fig/shapes-01-grayscale-dark.png similarity index 100% rename from episodes/fig/shapes-01-grayscale-dark.png rename to fig/shapes-01-grayscale-dark.png diff --git a/episodes/fig/shapes-01-grayscale.png b/fig/shapes-01-grayscale.png similarity index 100% rename from episodes/fig/shapes-01-grayscale.png rename to fig/shapes-01-grayscale.png diff --git a/episodes/fig/shapes-01-histogram.png b/fig/shapes-01-histogram.png similarity index 100% rename from episodes/fig/shapes-01-histogram.png rename to fig/shapes-01-histogram.png diff --git a/episodes/fig/shapes-01-labeled-dark.png b/fig/shapes-01-labeled-dark.png similarity index 100% rename from episodes/fig/shapes-01-labeled-dark.png rename to fig/shapes-01-labeled-dark.png diff --git a/episodes/fig/shapes-01-labeled.png b/fig/shapes-01-labeled.png similarity index 100% rename from episodes/fig/shapes-01-labeled.png rename to fig/shapes-01-labeled.png diff --git a/episodes/fig/shapes-01-mask-dark.png b/fig/shapes-01-mask-dark.png similarity index 100% rename from episodes/fig/shapes-01-mask-dark.png rename to fig/shapes-01-mask-dark.png diff --git a/episodes/fig/shapes-01-mask.png b/fig/shapes-01-mask.png similarity index 100% rename from episodes/fig/shapes-01-mask.png rename to fig/shapes-01-mask.png diff --git a/episodes/fig/shapes-01-objects-coloured-by-area-dark.png b/fig/shapes-01-objects-coloured-by-area-dark.png similarity index 100% rename from episodes/fig/shapes-01-objects-coloured-by-area-dark.png rename to fig/shapes-01-objects-coloured-by-area-dark.png diff --git a/episodes/fig/shapes-01-objects-coloured-by-area.png b/fig/shapes-01-objects-coloured-by-area.png similarity index 100% rename from episodes/fig/shapes-01-objects-coloured-by-area.png rename to fig/shapes-01-objects-coloured-by-area.png diff --git a/episodes/fig/shapes-01-selected-dark.png b/fig/shapes-01-selected-dark.png similarity index 100% rename from episodes/fig/shapes-01-selected-dark.png rename to fig/shapes-01-selected-dark.png diff --git a/episodes/fig/shapes-01-selected.png b/fig/shapes-01-selected.png similarity index 100% rename from episodes/fig/shapes-01-selected.png rename to fig/shapes-01-selected.png diff --git a/episodes/fig/shapes-01.jpg b/fig/shapes-01.jpg similarity index 100% rename from episodes/fig/shapes-01.jpg rename to fig/shapes-01.jpg diff --git a/episodes/fig/shapes-02-dark.jpg b/fig/shapes-02-dark.jpg similarity index 100% rename from episodes/fig/shapes-02-dark.jpg rename to fig/shapes-02-dark.jpg diff --git a/episodes/fig/shapes-02-histogram.png b/fig/shapes-02-histogram.png similarity index 100% rename from episodes/fig/shapes-02-histogram.png rename to fig/shapes-02-histogram.png diff --git a/episodes/fig/shapes-02-mask-dark.png b/fig/shapes-02-mask-dark.png similarity index 100% rename from episodes/fig/shapes-02-mask-dark.png rename to fig/shapes-02-mask-dark.png diff --git a/episodes/fig/shapes-02-mask.png b/fig/shapes-02-mask.png similarity index 100% rename from episodes/fig/shapes-02-mask.png rename to fig/shapes-02-mask.png diff --git a/episodes/fig/shapes-02-selected-dark.png b/fig/shapes-02-selected-dark.png similarity index 100% rename from episodes/fig/shapes-02-selected-dark.png rename to fig/shapes-02-selected-dark.png diff --git a/episodes/fig/shapes-02-selected.png b/fig/shapes-02-selected.png similarity index 100% rename from episodes/fig/shapes-02-selected.png rename to fig/shapes-02-selected.png diff --git a/episodes/fig/shapes-02.jpg b/fig/shapes-02.jpg similarity index 100% rename from episodes/fig/shapes-02.jpg rename to fig/shapes-02.jpg diff --git a/episodes/fig/source/06-blurring/create_blur_animation.py b/fig/source/06-blurring/create_blur_animation.py similarity index 100% rename from episodes/fig/source/06-blurring/create_blur_animation.py rename to fig/source/06-blurring/create_blur_animation.py diff --git a/episodes/fig/sudoku-dark.png b/fig/sudoku-dark.png similarity index 100% rename from episodes/fig/sudoku-dark.png rename to fig/sudoku-dark.png diff --git a/episodes/fig/sudoku-gray-dark.png b/fig/sudoku-gray-dark.png similarity index 100% rename from episodes/fig/sudoku-gray-dark.png rename to fig/sudoku-gray-dark.png diff --git a/episodes/fig/sudoku-gray.png b/fig/sudoku-gray.png similarity index 100% rename from episodes/fig/sudoku-gray.png rename to fig/sudoku-gray.png diff --git a/episodes/fig/sudoku.png b/fig/sudoku.png similarity index 100% rename from episodes/fig/sudoku.png rename to fig/sudoku.png diff --git a/episodes/fig/three-colours-dark.png b/fig/three-colours-dark.png similarity index 100% rename from episodes/fig/three-colours-dark.png rename to fig/three-colours-dark.png diff --git a/episodes/fig/three-colours.png b/fig/three-colours.png similarity index 100% rename from episodes/fig/three-colours.png rename to fig/three-colours.png diff --git a/episodes/fig/wellplate-01-dark.jpg b/fig/wellplate-01-dark.jpg similarity index 100% rename from episodes/fig/wellplate-01-dark.jpg rename to fig/wellplate-01-dark.jpg diff --git a/episodes/fig/wellplate-01-masked-dark.jpg b/fig/wellplate-01-masked-dark.jpg similarity index 100% rename from episodes/fig/wellplate-01-masked-dark.jpg rename to fig/wellplate-01-masked-dark.jpg diff --git a/episodes/fig/wellplate-01-masked.jpg b/fig/wellplate-01-masked.jpg similarity index 100% rename from episodes/fig/wellplate-01-masked.jpg rename to fig/wellplate-01-masked.jpg diff --git a/episodes/fig/wellplate-01.jpg b/fig/wellplate-01.jpg similarity index 100% rename from episodes/fig/wellplate-01.jpg rename to fig/wellplate-01.jpg diff --git a/episodes/fig/wellplate-02-dark.jpg b/fig/wellplate-02-dark.jpg similarity index 100% rename from episodes/fig/wellplate-02-dark.jpg rename to fig/wellplate-02-dark.jpg diff --git a/episodes/fig/wellplate-02-histogram-dark.png b/fig/wellplate-02-histogram-dark.png similarity index 100% rename from episodes/fig/wellplate-02-histogram-dark.png rename to fig/wellplate-02-histogram-dark.png diff --git a/episodes/fig/wellplate-02-histogram.png b/fig/wellplate-02-histogram.png similarity index 100% rename from episodes/fig/wellplate-02-histogram.png rename to fig/wellplate-02-histogram.png diff --git a/episodes/fig/wellplate-02-masked-dark.jpg b/fig/wellplate-02-masked-dark.jpg similarity index 100% rename from episodes/fig/wellplate-02-masked-dark.jpg rename to fig/wellplate-02-masked-dark.jpg diff --git a/episodes/fig/wellplate-02-masked.jpg b/fig/wellplate-02-masked.jpg similarity index 100% rename from episodes/fig/wellplate-02-masked.jpg rename to fig/wellplate-02-masked.jpg diff --git a/episodes/fig/wellplate-02.jpg b/fig/wellplate-02.jpg similarity index 100% rename from episodes/fig/wellplate-02.jpg rename to fig/wellplate-02.jpg diff --git a/episodes/fig/zero-dark.png b/fig/zero-dark.png similarity index 100% rename from episodes/fig/zero-dark.png rename to fig/zero-dark.png diff --git a/episodes/fig/zero.png b/fig/zero.png similarity index 100% rename from episodes/fig/zero.png rename to fig/zero.png diff --git a/files/assets/dc-logo-white.svg b/files/assets/dc-logo-white.svg new file mode 100644 index 000000000..7336a5d28 --- /dev/null +++ b/files/assets/dc-logo-white.svg @@ -0,0 +1,34 @@ + + + + diff --git a/files/assets/fixed_cells_masked.png b/files/assets/fixed_cells_masked.png new file mode 100644 index 000000000..fc7272a3c Binary files /dev/null and b/files/assets/fixed_cells_masked.png differ diff --git a/episodes/files/cheatsheet.html b/files/cheatsheet.html similarity index 100% rename from episodes/files/cheatsheet.html rename to files/cheatsheet.html diff --git a/episodes/files/cheatsheet.pdf b/files/cheatsheet.pdf similarity index 100% rename from episodes/files/cheatsheet.pdf rename to files/cheatsheet.pdf diff --git a/episodes/files/environment.yml b/files/environment.yml similarity index 100% rename from episodes/files/environment.yml rename to files/environment.yml diff --git a/further-reading.html b/further-reading.html new file mode 100644 index 000000000..4300ca210 --- /dev/null +++ b/further-reading.html @@ -0,0 +1,444 @@ + +Image Processing with Python: Further Reading +
+
+ + + + + + + + +
+
+ + + diff --git a/image-processing.Rproj b/image-processing.Rproj deleted file mode 100644 index aecd28b8b..000000000 --- a/image-processing.Rproj +++ /dev/null @@ -1,18 +0,0 @@ -Version: 1.0 - -RestoreWorkspace: Default -SaveWorkspace: Default -AlwaysSaveHistory: Default - -EnableCodeIndexing: Yes -UseSpacesForTab: Yes -NumSpacesForTab: 2 -Encoding: UTF-8 - -RnwWeave: Sweave -LaTeX: pdfLaTeX - -AutoAppendNewline: Yes -StripTrailingWhitespace: Yes - -BuildType: Website diff --git a/images.html b/images.html new file mode 100644 index 000000000..022a4f5da --- /dev/null +++ b/images.html @@ -0,0 +1,799 @@ + + + + + +Image Processing with Python: All Images + + + + + + + + + + + + +
+
+ + + + + + +
+
+

All Images

+ +

Introduction

+
+

Figure 1

+ +
Bacteria colony

+

Figure 2

+ +
Colonies counted

+

Figure 3

+ +
Bacteria colony

Image Basics

+
+

Figure 1

+ +
Original size image

+

Figure 2

+ +
Enlarged image area

+

Figure 3

+ +
Image of 8

+

Figure 4

+ +
Image of 0

+

Figure 5

+ +
Cartesian coordinate system

+

Figure 6

+ +
Image coordinate system

+

Figure 7

+ +
Left-hand coordinate system

+

Figure 8

+ +
Image of 5

+

Figure 9

+ +
Image of three colours

+

Figure 10

+ +
Image in greyscale

+

Figure 11

+ +
Image of checkerboard

+

Figure 12

+ +
Image of red channel

+

Figure 13

+ +
Image of green channel

+

Figure 14

+ +
Image of blue channel

+

Figure 15

+ +
RGB colour table

+

Figure 16

+ +
Original image

+

Figure 17

+ +
Enlarged, uncompressed

+

Figure 18

+ +
Enlarged, compressed

+

Figure 19

+ +
Uncompressed histogram

Working with scikit-image

+
+

Figure 1

+ +
Root cluster image

+

Figure 2

+ +
Thresholded root image

+

Figure 3

+ +
Su-Do-Ku puzzle

+

Figure 4

+ +
Modified Su-Do-Ku puzzle

+

Figure 5

+ +
Whiteboard image

+

Figure 6

+ +
Whiteboard coordinates

+

Figure 7

+ +
"Erased" whiteboard

Drawing and Bitwise Operations

+
+

Figure 1

+ +
Maize seedlings

+

Figure 2

+ +

Here is what our constructed mask looks like: Maize image mask

+
+

Figure 3

+ +
Sample shapes

+

Figure 4

+ +
Applied mask

+

Figure 5

+ +
Remote control image

+

Figure 6

+ +
Remote control masked

+

Figure 7

+ +
96-well plate

+

Figure 8

+ +
Masked 96-well plate

Creating Histograms

+
+

Figure 1

+ +

We will start with grayscale images, and then move on to colour +images. We will use this image of a plant seedling as an example: Plant seedling

+
+

Figure 2

+ +
Plant seedling

+

Figure 3

+ +
Plant seedling histogram

+

Figure 4

+ +
Grayscale histogram of masked area

+

Figure 5

+ +
Colour histogram

+

Figure 6

+ +
Well plate image

+

Figure 7

+ +
Masked well plate

+

Figure 8

+ +
Well plate histogram

Blurring Images

+
+

Figure 1

+ +
Cat image

+

Figure 2

+ +
Cat eye pixels

+

Figure 3

+ +

A Gaussian function maps random variables into a normal distribution +or “Bell Curve”. Gaussian function

+
+

Figure 4

+ +
2D Gaussian function

+

Figure 5

+ +
2D Gaussian function

+

Figure 6

+ +
Image corner pixels

+

Figure 7

+ +
Image multiplication

+

Figure 8

+ +
Blur demo animation

+

Figure 9

+ +
Original image

+

Figure 10

+ +
Blurred image

+

Figure 11

+ +
Bacteria colony
Graysacle version of the Petri dish image
+

+

Figure 12

+ +
Bacteria colony image with selected pixels marker
Grayscale Petri dish image marking selected +pixels for profiling
+

+

Figure 13

+ +
Pixel intensities profile in original image
Intensities profile line plot of pixels along +Y=150 in original image
+

+

Figure 14

+ +
Pixel intensities profile in blurred image
Intensities profile of pixels along Y=150 in +blurred image
+

+

Figure 15

+ +
3D surface plot showing pixel intensities across the whole example Petri dish image before blurring
A 3D plot of pixel intensities across the whole +Petri dish image before blurring. Explore +how this plot was created with matplotlib. Image credit: Carlos H Brandt.
+

+

Figure 16

+ +
3D surface plot illustrating the smoothing effect on pixel intensities across the whole example Petri dish image after blurring
A 3D plot of pixel intensities after Gaussian +blurring of the Petri dish image. Note the ‘smoothing’ effect on the +pixel intensities of the colonies in the image, and the ‘flattening’ of +the background noise at relatively low pixel intensities throughout the +image. Explore +how this plot was created with matplotlib. Image credit: Carlos H Brandt.
+

+

Figure 17

+ +
Rectangular kernel blurred image

Thresholding

+
+

Figure 1

+ +
Image with geometric shapes on white background

+

Figure 2

+ +
Grayscale image of the geometric shapes

+

Figure 3

+ +
Grayscale histogram of the geometric shapes image

+

Figure 4

+ +
Binary mask of the geometric shapes created by thresholding

+

Figure 5

+ +
Selected shapes after applying binary mask

+

Figure 6

+ +
Another image with geometric shapes on white background

+

Figure 7

+ +
Grayscale histogram of the second geometric shapes image

+

Figure 8

+ +
Binary mask created by thresholding the second geometric shapes image

+

Figure 9

+ +
Selected shapes after applying binary mask to the second geometric shapes image

+

Figure 10

+ +
Image of a maize root

+

Figure 11

+ +
Grayscale histogram of the maize root image

+

Figure 12

+ +
Binary mask of the maize root system

+

Figure 13

+ +
Masked selection of the maize root system

+

Figure 14

+ +
Four images of maize roots

+

Figure 15

+ +
Binary masks of the four maize root images

+

Figure 16

+ +
Improved binary masks of the four maize root images

+

Figure 17

+ +
Image of bacteria colonies in a petri dish

+

Figure 18

+ +
Grayscale histogram of the bacteria colonies image

+

Figure 19

+ +
Binary mask of the bacteria colonies image

Connected Component Analysis

+
+

Figure 1

+ +
Original shapes image

+

Figure 2

+ +
Mask created by thresholding

+

Figure 3

+ +
Labeled objects

+

Figure 4

+ +
shapes-01.jpg mask detail

+

Figure 5

+ +
Histogram of object areas

+

Figure 6

+ +
Objects filtered by area

+

Figure 7

+ +
Objects colored by area

Capstone Challenge

+
+

Figure 1

+ +
Colony image 1

+

Figure 2

+ +
Colony image 2

+

Figure 3

+ +
Colony image 3

+

Figure 4

+ +
Sample morphometric output

+

Figure 5

+ +
Colony image 1

+

Figure 6

+ +
Gray Colonies

+

Figure 7

+ +
Histogram image

+

Figure 8

+ +
Colony mask image

+

Figure 9

+ +
Sample morphometric output

+

Figure 10

+ + + +

Colony 1 outputColony 2 outputColony 3 output

+
+
+
+
+ + +
+ + +
+ + + + + diff --git a/index.html b/index.html new file mode 100644 index 000000000..5930c30a2 --- /dev/null +++ b/index.html @@ -0,0 +1,602 @@ + +Image Processing with Python: Summary and Setup +
+
+ + + + + +
+

Summary and Setup

+ + +

This lesson shows how to use Python and scikit-image to do basic +image processing.

+
+
+ +
+Prerequisite +
+

Prerequisites

+
+

This lesson assumes you have a working knowledge of Python and some +previous exposure to the Bash shell. These requirements can be fulfilled +by: a) completing a Software Carpentry Python workshop +or b) completing a Data Carpentry Ecology workshop +(with Python) and a Data Carpentry Genomics workshop +or c) independent exposure to both Python and the Bash +shell.

+

If you’re unsure whether you have enough experience to participate in +this workshop, please read over this detailed +list, which gives all of the functions, operators, and other +concepts you will need to be familiar with.

+
+
+
+

Before following the lesson, please make sure +you have the software and data required.

+ + +

Before joining the workshop or following the lesson, please complete +the data and software setup described in this page.

+

Data

+

The example images and a description of the Python environment used +in this lesson are available on FigShare. To download the data, please +visit the +dataset page for this workshop and click the “Download all” button. +Unzip the downloaded file, and save the contents as a folder called +data somewhere you will easily find it again, e.g. your +Desktop or a folder you have created for using in this workshop. (The +name data is optional but recommended, as this is the name +we will use to refer to the folder throughout the lesson.)

+

Software

+
  1. Download and install the latest Miniforge distribution of +Python for your operating system. (See +more detailed instructions from The Carpentries.) If you already +have a Python 3 setup that you are happy with, you can continue to use +that (we recommend that you make sure your Python version is current). +The next step assumes that conda is available to manage +your Python environment.

  2. +
  3. +

    Set up an environment to work in during the lesson. In a terminal +(Linux/Mac) or the MiniForge Prompt application (Windows), navigate to +the location where you saved the unzipped data for the lesson and run +the following command:

    +
    +

    BASH +

    +
    conda env create -f environment.yml
    +
    +

    If prompted, allow conda to install the required +libraries.

    +
  4. +
  5. +

    Activate the new environment you just created:

    +
    +

    BASH +

    +
    conda activate dc-image
    +
    +
    +
    + +
    +Callout +
    +

    Enabling the ipympl backend in +Jupyter notebooks

    +
    +

    The ipympl backend can be enabled with the +%matplotlib Jupyter magic. Put the following command in a +cell in your notebooks (e.g., at the top) and execute the cell before +any plotting commands.

    +
    +

    PYTHON +

    +
    %matplotlib widget
    +
    +
    +
    +
    +
    +
    + +
    +Callout +
    +

    Older +JupyterLab versions

    +
    +

    If you are using an older version of JupyterLab, you may also need to +install the labextensions manually, as explained in the README file for +the ipympl package.

    +
    +
    +
    +
  6. +
  7. +

    Open a Jupyter notebook:

    +
    +
    + +
    +
    +

    Open a terminal and type jupyter lab.

    +
    +
    +
    +
    +
    +
    + +
    +
    +

    Launch the Miniforge Prompt program and type +jupyter lab. (Running this command on the standard Command +Prompt will return an error: +'jupyter' is not recognized as an internal or external command, operable program or batch file.)

    +
    +
    +
    +
    +

    After Jupyter Lab has launched, click the “Python 3” button under +“Notebook” in the launcher window, or use the “File” menu, to open a new +Python 3 notebook.

    +
  8. +
  9. +

    To test your environment, run the following lines in a cell of +the notebook:

    +
    +

    PYTHON +

    +
    import imageio.v3 as iio
    +import matplotlib.pyplot as plt
    +import skimage as ski
    +
    +%matplotlib widget
    +
    +# load an image
    +image = iio.imread(uri='data/colonies-01.tif')
    +
    +# rotate it by 45 degrees
    +rotated = ski.transform.rotate(image=image, angle=45)
    +
    +# display the original image and its rotated version side by side
    +fig, ax = plt.subplots(1, 2)
    +ax[0].imshow(image)
    +ax[1].imshow(rotated)
    +
    +

    Upon execution of the cell, a figure with two images should be +displayed in an interactive widget. When hovering over the images with +the mouse pointer, the pixel coordinates and colour values are displayed +below the image.

    +
    +
    + +
    +
    +

    Overview of the Jupyter Notebook graphical user interface To +run Python code in a Jupyter notebook cell, click on a cell in the +notebook (or add a new one by clicking the + button in the +toolbar), make sure that the cell type is set to “Code” (check the +dropdown in the toolbar), and add the Python code in that cell. After +you have added the code, you can run the cell by selecting “Run” -> +“Run selected cell” in the top menu, or pressing +Shift+Enter.

    +
    +
    +
    +
    +
  10. +
  11. A small number of exercises will require you to run commands in a +terminal. Windows users should use PowerShell for this. PowerShell is +probably installed by default but if not you should download +and install it.

  12. +
+ + +
+
+ + + diff --git a/index.md b/index.md deleted file mode 100644 index b7a9af673..000000000 --- a/index.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -site: sandpaper::sandpaper_site ---- - -This lesson shows how to use Python and scikit-image to do basic image processing. - -:::::::::::::::::::::::::::::::::::::::::: prereq - -## Prerequisites - -This lesson assumes you have a working knowledge of Python and some previous exposure to the Bash shell. -These requirements can be fulfilled by: -a) completing a Software Carpentry Python workshop **or** -b) completing a Data Carpentry Ecology workshop (with Python) **and** a Data Carpentry Genomics workshop **or** -c) independent exposure to both Python and the Bash shell. - -If you're unsure whether you have enough experience to participate in this workshop, please read over -[this detailed list](learners/prereqs.md), which gives all of the functions, operators, and other concepts you will need -to be familiar with. - - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -Before following the lesson, please [make sure you have the software and data required](learners/setup.md). diff --git a/instructor-notes.html b/instructor-notes.html new file mode 100644 index 000000000..fbf69817d --- /dev/null +++ b/instructor-notes.html @@ -0,0 +1,615 @@ + + + + + +Image Processing with Python: Instructor Notes + + + + + + + + + + + + +
+
+ + + + + + +
+
+

Instructor Notes

+ + +

Estimated Timings +

+
+

This is a relatively new curriculum. The estimated timings for each +episode are based on limited experience and should be taken as a rough +guide only. If you teach the curriculum, the Maintainers would be +delighted to receive feedback with information about the time that was +required for teaching and exercises in each episode of your +workshop.

+

Please open +an issue on the repository to share your experience with the lesson +Maintainers.

+

Working with Jupyter notebooks +

+
+
    +
  • This lesson is designed to be taught using Jupyter notebooks. We +recommend that instructors guide learners to create a new Jupyter +notebook for each episode.

  • +
  • Python import statements typically appear in the +first code block near the top of each episode. In some cases, the +purpose of specific libraries is briefly explained as part of the +exercises.

  • +
  • The possibility of executing the code cells in a notebook in +arbitrary order can cause confusion. Using the “restart kernel and run +all cells” feature is one way to accomplish linear execution of the +notebook and may help locate and identify coding issues.

  • +
  • Many episodes in this lesson load image files from disk. To avoid +name clashes in episodes that load multiple image files, we have used +unique variable names (instead of generic names such as +image or img). When copying code snippets +between exercises, the variable names may have to be changed. The +maintainers are keen to receive feedback on whether this convention +proves practical in workshops.

  • +

Working with imageio and skimage +

+
+
    +
  • imageio.v3 allows to load images in different modes +by passing the mode= argument to imread(). +Depending on the image file and mode, the dtype of the +resulting Numpy array can be different (e.g., +dtype('uint8') or dtype('float64'). In the +lesson, skimage.util.img_as_ubyte() and +skimage.util.img_as_float() are used to convert the data +type when necessary.

  • +
  • Some skimage functions implicitly convert the pixel +values to floating-point numbers. Several callout boxes have been added +throughout the lesson to raise awareness, but this may still prompt +questions from learners.

  • +
  • In certain situations, imread() returns a read-only +array. This depends on the image file type and on the backend (e.g., +Pillow). If a read-only error is encountered, +image = np.array(image) can be used to create a writable +copy of the array before manipulating its pixel values.

  • +
  • Be aware that learners might get surprising results in the +Keeping only low intensity pixels exercise, if +plt.imshow is called without the vmax +parameter. A detailed explanation is given in the Plotting single +channel images (cmap, vmin, vmax) callout box.

  • +

Additional resources +

+
+

Questions from Learners +

+
+
+

Q: Where would I find out that coordinates are x,y not +r,c? +

+

A: In an image viewer, hover your cursor over top-left (origin) the +move down and see which number increases.

+
+
+

Q: Why does saving the image take such a long time? +(skimage-images/saving images PNG example) +

+

A: It is a large image.

+
+
+

Q: Are the coordinates represented x,y or +r,c in the code (e.g. in array.shape)? +

+

A: Always r,c with numpy arrays, unless clearly +specified otherwise - only represented x,y when image is +displayed by a viewer. Take home is don’t rely on it - always check!

+
+
+

Q: What if I want to increase size? How does skimage +upsample? (image resizing) +

+

A: When resizing or rescaling an image, skimage performs +interpolation to up-size or down-size the image. Technically, this is +done by fitting a spline +function to the image data. The spline function is based on the +intensity values in the original image and can be used to approximate +the intensity at any given coordinate in the resized/rescaled image. +Note that the intensity values in the new image are an approximation of +the original values but should not be treated as the actual, observed +data. skimage.transform.resize has a number of optional +parameters that allow the user to control, e.g., the order of the spline +interpolation. The scikit-image +documentation provides additional information on other +parameters.

+
+
+

Q: Why are some lines missing from the sudoku image when it is +displayed inline in a Jupyter Notebook? (skimage-images/low intensity +pixels exercise) +

+

A: They are actually present in image but not shown due to +interpolation.

+
+
+

Q: Does blurring take values of pixels already blurred, or is +blurring done on original pixel values only? +

+

A: Blurring is done on original pixel values only.

+
+
+

Q: Can you blur while retaining edges? +

+

A: Yes, many different filters/kernels exist, some of which are +designed to be edge-preserving.

+
+

Troubleshooting +

+
+

Learners reported a problem on some operating systems, that +Shift+Enter is prevented from running a cell in +Jupyter when the caps lock key is active.

+
+
+
+
+ + +
+ + +
+ + + + + diff --git a/instructor/01-introduction.html b/instructor/01-introduction.html new file mode 100644 index 000000000..2a7a528d1 --- /dev/null +++ b/instructor/01-introduction.html @@ -0,0 +1,561 @@ + +Image Processing with Python: Introduction +
+
+ + + + + +
+
+

Introduction

+

Last updated on 2024-11-28 | + + Edit this page

+ + + +

Estimated time: 5 minutes

+ +
+ +
+ + + +
+

Overview

+
+
+
+
+

Questions

+
  • What sort of scientific questions can we answer with image +processing / computer vision?
  • +
  • What are morphometric problems?
  • +
+
+
+
+
+
+

Objectives

+
  • Recognise scientific questions that could be solved with image +processing / computer vision.
  • +
  • Recognise morphometric problems (those dealing with the number, +size, or shape of the objects in an image).
  • +
+
+
+
+
+

As computer systems have become faster and more powerful, and cameras +and other imaging systems have become commonplace in many other areas of +life, the need has grown for researchers to be able to process and +analyse image data. Considering the large volumes of data that can be +involved - high-resolution images that take up a lot of disk +space/virtual memory, and/or collections of many images that must be +processed together - and the time-consuming and error-prone nature of +manual processing, it can be advantageous or even necessary for this +processing and analysis to be automated as a computer program.

+

This lesson introduces an open source toolkit for processing image +data: the Python programming language and the scikit-image +(skimage) library. With careful experimental design, +Python code can be a powerful instrument in answering many different +kinds of questions.

+

Uses of Image Processing in Research

+

Automated processing can be used to analyse many different properties +of an image, including the distribution and change in colours in the +image, the number, size, position, orientation, and shape of objects in +the image, and even - when combined with machine learning techniques for +object recognition - the type of objects in the image.

+

Some examples of image processing methods applied in research +include:

+

With this lesson, we aim to provide a thorough grounding in the +fundamental concepts and skills of working with image data in Python. +Most of the examples used in this lesson focus on one particular class +of image processing technique, morphometrics, but what you will +learn can be used to solve a much wider range of problems.

+

Morphometrics

+

Morphometrics involves counting the number of objects in an image, +analyzing the size of the objects, or analyzing the shape of the +objects. For example, we might be interested in automatically counting +the number of bacterial colonies growing in a Petri dish, as shown in +this image:

+
Bacteria colony

We could use image processing to find the colonies, count them, and +then highlight their locations on the original image, resulting in an +image like this:

+
Colonies counted
+
+ +
+Callout +
+

Why write a program to do that?

+
+

Note that you can easily manually count the number of bacteria +colonies shown in the morphometric example above. Why should we learn +how to write a Python program to do a task we could easily perform with +our own eyes? There are at least two reasons to learn how to perform +tasks like these with Python and scikit-image:

+
  1. What if there are many more bacteria colonies in the Petri dish? For +example, suppose the image looked like this:
  2. +
Bacteria colony

Manually counting the colonies in that image would present more of a +challenge. A Python program using scikit-image could count the number of +colonies more accurately, and much more quickly, than a human could.

+
  1. What if you have hundreds, or thousands, of images to consider? +Imagine having to manually count colonies on several thousand images +like those above. A Python program using scikit-image could move through +all of the images in seconds; how long would a graduate student require +to do the task? Which process would be more accurate and +repeatable?
  2. +

As you can see, the simple image processing / computer vision +techniques you will learn during this workshop can be very valuable +tools for scientific research.

+
+
+
+

As we move through this workshop, we will learn image analysis +methods useful for many different scientific problems. These will be +linked together and applied to a real problem in the final +end-of-workshop capstone challenge.

+

Let’s get started, by learning some basics about how images are +represented and stored digitally.

+
+
+ +
+Key Points +
+
+
  • Simple Python and scikit-image techniques can be used to solve +genuine image analysis problems.
  • +
  • Morphometric problems involve the number, shape, and / or size of +the objects in an image.
  • +
+
+
+
+
+ + +
+
+ + + diff --git a/instructor/02-image-basics.html b/instructor/02-image-basics.html new file mode 100644 index 000000000..b15599869 --- /dev/null +++ b/instructor/02-image-basics.html @@ -0,0 +1,1601 @@ + +Image Processing with Python: Image Basics +
+
+ + + + + +
+
+

Image Basics

+

Last updated on 2024-12-01 | + + Edit this page

+ + + +

Estimated time: 25 minutes

+ +
+ +
+ + + +
+

Overview

+
+
+
+
+

Questions

+
  • How are images represented in digital format?
  • +
+
+
+
+
+
+

Objectives

+
  • Define the terms bit, byte, kilobyte, megabyte, etc.
  • +
  • Explain how a digital image is composed of pixels.
  • +
  • Recommend using imageio (resp. scikit-image) for I/O (resp. image +processing) tasks.
  • +
  • Explain how images are stored in NumPy arrays.
  • +
  • Explain the left-hand coordinate system used in digital images.
  • +
  • Explain the RGB additive colour model used in digital images.
  • +
  • Explain the order of the three colour values in scikit-image +images.
  • +
  • Explain the characteristics of the BMP, JPEG, and TIFF image +formats.
  • +
  • Explain the difference between lossy and lossless compression.
  • +
  • Explain the advantages and disadvantages of compressed image +formats.
  • +
  • Explain what information could be contained in image metadata.
  • +
+
+
+
+
+

The images we see on hard copy, view with our electronic devices, or +process with our programs are represented and stored in the computer as +numeric abstractions, approximations of what we see with our eyes in the +real world. Before we begin to learn how to process images with Python +programs, we need to spend some time understanding how these +abstractions work.

+
+
+ +
+Callout +
+
+

Feel free to make use of the available cheat-sheet as a guide for +the rest of the course material. View it online, share it, or print the +PDF!

+
+
+
+

Pixels

+

It is important to realise that images are stored as rectangular +arrays of hundreds, thousands, or millions of discrete “picture +elements,” otherwise known as pixels. Each pixel can be thought +of as a single square point of coloured light.

+

For example, consider this image of a maize seedling, with a square +area designated by a red box:

+
Original size image

Now, if we zoomed in close enough to see the pixels in the red box, +we would see something like this:

+
Enlarged image area

Note that each square in the enlarged image area - each pixel - is +all one colour, but that each pixel can have a different colour from its +neighbors. Viewed from a distance, these pixels seem to blend together +to form the image we see.

+

Real-world images are typically made up of a vast number of pixels, +and each of these pixels is one of potentially millions of colours. +While we will deal with pictures of such complexity in this lesson, +let’s start our exploration with just 15 pixels in a 5 x 3 matrix with 2 +colours, and work our way up to that complexity.

+
+
+ +
+Callout +
+

Matrices, arrays, images and pixels

+
+

A matrix is a mathematical concept - numbers evenly +arranged in a rectangle. This can be a two-dimensional rectangle, like +the shape of the screen you’re looking at now. Or it could be a +three-dimensional equivalent, a cuboid, or have even more dimensions, +but always keeping the evenly spaced arrangement of numbers. In +computing, an array refers to a structure in the +computer’s memory where data is stored in evenly spaced +elements. This is strongly analogous to a matrix. A +NumPy array is a type of variable (a simpler example of +a type is an integer). For our purposes, the distinction between +matrices and arrays is not important, we don’t really care how the +computer arranges our data in its memory. The important thing is that +the computer stores values describing the pixels in images, as arrays. +And the terms matrix and array will be used interchangeably.

+
+
+
+

Loading images

+

As noted, images we want to analyze (process) with Python are loaded +into arrays. There are multiple ways to load images. In this lesson, we +use imageio, a Python library for reading (loading) and writing (saving) +image data, and more specifically its version 3. But, really, we could +use any image loader which would return a NumPy array.

+
+

PYTHON +

+
"""Python library for reading and writing images."""
+
+import imageio.v3 as iio
+
+

The v3 module of imageio (imageio.v3) is +imported as iio (see note in the next section). Version 3 +of imageio has the benefit of supporting nD (multidimensional) image +data natively (think of volumes, movies).

+

Let us load our image data from disk using the imread +function from the imageio.v3 module.

+
+

PYTHON +

+
eight = iio.imread(uri="data/eight.tif")
+print(type(eight))
+
+
+

OUTPUT +

+
<class 'numpy.ndarray'>
+
+

Note that, using the same image loader or a different one, we could +also read in remotely hosted data.

+
+
+ +
+Callout +
+

Why not use +skimage.io.imread()?

+
+

The scikit-image library has its own function to read an image, so +you might be asking why we don’t use it here. Actually, +skimage.io.imread() uses iio.imread() +internally when loading an image into Python. It is certainly something +you may use as you see fit in your own code. In this lesson, we use the +imageio library to read or write images, while scikit-image is dedicated +to performing operations on the images. Using imageio gives us more +flexibility, especially when it comes to handling metadata.

+
+
+
+
+
+ +
+Callout +
+

Beyond NumPy arrays

+
+

Beyond NumPy arrays, there exist other types of variables which are +array-like. Notably, pandas.DataFrame +and xarray.DataArray +can hold labeled, tabular data. These are not natively supported in +scikit-image, the scientific toolkit we use in this lesson for +processing image data. However, data stored in these types can be +converted to numpy.ndarray with certain assumptions (see +pandas.DataFrame.to_numpy() and +xarray.DataArray.data). Particularly, these conversions +ignore the sampling coordinates (DataFrame.index, +DataFrame.columns, or DataArray.coords), which +may result in misrepresented data, for instance, when the original data +points are irregularly spaced.

+
+
+
+

Working with pixels

+

First, let us add the necessary imports:

+
+

PYTHON +

+
"""Python libraries for learning and performing image processing."""
+
+import ipympl
+import matplotlib.pyplot as plt
+import numpy as np
+import skimage as ski
+
+
+
+ +
+Callout +
+

Import statements in Python

+
+

In Python, the import statement is used to load +additional functionality into a program. This is necessary when we want +our code to do something more specialised, which cannot easily be +achieved with the limited set of basic tools and data structures +available in the default Python environment.

+

Additional functionality can be loaded as a single function or +object, a module defining several of these, or a library containing many +modules. You will encounter several different forms of +import statement.

+
+

PYTHON +

+
import skimage                 # form 1, load whole skimage library
+import skimage.draw            # form 2, load skimage.draw module only
+from skimage.draw import disk  # form 3, load only the disk function
+import skimage as ski          # form 4, load all of skimage into an object called ski
+
+
+
+ +
+
+

In the example above, form 1 loads the entire scikit-image library +into the program as an object. Individual modules of the library are +then available within that object, e.g., to access the disk +function used in the drawing episode, you +would write skimage.draw.disk().

+

Form 2 loads only the draw module of +skimage into the program. The syntax needed to use the +module remains unchanged: to access the disk function, we +would use the same function call as given for form 1.

+

Form 3 can be used to import only a specific function/class from a +library/module. Unlike the other forms, when this approach is used, the +imported function or class can be called by its name only, without +prefixing it with the name of the library/module from which it was +loaded, i.e., disk() instead of +skimage.draw.disk() using the example above. One hazard of +this form is that importing like this will overwrite any object with the +same name that was defined/imported earlier in the program, i.e., the +example above would replace any existing object called disk +with the disk function from skimage.draw.

+

Finally, the as keyword can be used when importing, to +define a name to be used as shorthand for the library/module being +imported. This name is referred to as an alias. Typically, using an +alias (such as np for the NumPy library) saves us a little +typing. You may see as combined with any of the other first +three forms of import statements.

+

Which form is used often depends on the size and number of additional +tools being loaded into the program.

+
+
+
+
+
+
+
+

Now that we have our libraries loaded, we will run a Jupyter Magic +Command that will ensure our images display in our Jupyter document with +pixel information that will help us more efficiently run commands later +in the session.

+
+

PYTHON +

+
%matplotlib widget
+
+

With that taken care of, let us display the image we have loaded, +using the imshow function from the +matplotlib.pyplot module.

+
+

PYTHON +

+
fig, ax = plt.subplots()
+ax.imshow(eight)
+
+
Image of 8

You might be thinking, “That does look vaguely like an eight, and I +see two colours but how can that be only 15 pixels”. The display of the +eight you see does use a lot more screen pixels to display our eight so +large, but that does not mean there is information for all those screen +pixels in the file. All those extra pixels are a consequence of our +viewer creating additional pixels through interpolation. It could have +just displayed it as a tiny image using only 15 screen pixels if the +viewer was designed differently.

+

While many image file formats contain descriptive metadata that can +be essential, the bulk of a picture file is just arrays of numeric +information that, when interpreted according to a certain rule set, +become recognizable as an image to us. Our image of an eight is no +exception, and imageio.v3 stored that image data in an +array of arrays making a 5 x 3 matrix of 15 pixels. We can demonstrate +that by calling on the shape property of our image variable and see the +matrix by printing our image variable to the screen.

+
+

PYTHON +

+
print(eight.shape)
+print(eight)
+
+
+

OUTPUT +

+
(5, 3)
+[[0. 0. 0.]
+ [0. 1. 0.]
+ [0. 0. 0.]
+ [0. 1. 0.]
+ [0. 0. 0.]]
+
+

Thus if we have tools that will allow us to manipulate these arrays +of numbers, we can manipulate the image. The NumPy library can be +particularly useful here, so let’s try that out using NumPy array +slicing. Notice that the default behavior of the imshow +function appended row and column numbers that will be helpful to us as +we try to address individual or groups of pixels. First let’s load +another copy of our eight, and then make it look like a zero.

+

To make it look like a zero, we need to change the number underlying +the centremost pixel to be 1. With the help of those row and column +headers, at this small scale we can determine the centre pixel is in row +labeled 2 and column labeled 1. Using array slicing, we can then address +and assign a new value to that position.

+
+

PYTHON +

+
zero = iio.imread(uri="data/eight.tif")
+zero[2, 1]= 1.0
+
+# The following line of code creates a new figure for imshow to use in displaying our output.
+fig, ax = plt.subplots()
+ax.imshow(zero)
+print(zero)
+
+
+

OUTPUT +

+
[[0. 0. 0.]
+ [0. 1. 0.]
+ [0. 1. 0.]
+ [0. 1. 0.]
+ [0. 0. 0.]]
+
+
Image of 0
+
+ +
+Callout +
+

Coordinate system

+
+

When we process images, we can access, examine, and / or change the +colour of any pixel we wish. To do this, we need some convention on how +to access pixels individually; a way to give each one a name, or an +address of a sort.

+

The most common manner to do this, and the one we will use in our +programs, is to assign a modified Cartesian coordinate system to the +image. The coordinate system we usually see in mathematics has a +horizontal x-axis and a vertical y-axis, like this:

+
Cartesian coordinate system

The modified coordinate system used for our images will have only +positive coordinates, the origin will be in the upper left corner +instead of the centre, and y coordinate values will get larger as they +go down instead of up, like this:

+
Image coordinate system

This is called a left-hand coordinate system. If you hold +your left hand in front of your face and point your thumb at the floor, +your extended index finger will correspond to the x-axis while your +thumb represents the y-axis.

+
Left-hand coordinate system

Until you have worked with images for a while, the most common +mistake that you will make with coordinates is to forget that y +coordinates get larger as they go down instead of up as in a normal +Cartesian coordinate system. Consequently, it may be helpful to think in +terms of counting down rows (r) for the y-axis and across columns (c) +for the x-axis. This can be especially helpful in cases where you need +to transpose image viewer data provided in x,y format to +y,x format. Thus, we will use cx and ry where +appropriate to help bridge these two approaches.

+
+
+
+
+
+ +
+Challenge +
+

Changing Pixel Values (5 min)

+
+

Load another copy of eight named five, and then change the value of +pixels so you have what looks like a 5 instead of an 8. Display the +image and print out the matrix as well.

+
+
+
+
+
+ +
+
+

There are many possible solutions, but one method would be . . .

+
+

PYTHON +

+
five = iio.imread(uri="data/eight.tif")
+five[1, 2] = 1.0
+five[3, 0] = 1.0
+fig, ax = plt.subplots()
+ax.imshow(five)
+print(five)
+
+
+

OUTPUT +

+
[[0. 0. 0.]
+ [0. 1. 1.]
+ [0. 0. 0.]
+ [1. 1. 0.]
+ [0. 0. 0.]]
+
+
Image of 5
+
+
+
+

More colours

+

Up to now, we only had a 2 colour matrix, but we can have more if we +use other numbers or fractions. One common way is to use the numbers +between 0 and 255 to allow for 256 different colours or 256 different +levels of grey. Let’s try that out.

+
+

PYTHON +

+
# make a copy of eight
+three_colours = iio.imread(uri="data/eight.tif")
+
+# multiply the whole matrix by 128
+three_colours = three_colours * 128
+
+# set the middle row (index 2) to the value of 255.,
+# so you end up with the values 0., 128., and 255.
+three_colours[2, :] = 255.
+fig, ax = plt.subplots()
+ax.imshow(three_colours)
+print(three_colours)
+
+
Image of three colours

We now have 3 colours, but are they the three colours you expected? +They all appear to be on a continuum of dark purple on the low end and +yellow on the high end. This is a consequence of the default colour map +(cmap) in this library. You can think of a colour map as an association +or mapping of numbers to a specific colour. However, the goal here is +not to have one number for every possible colour, but rather to have a +continuum of colours that demonstrate relative intensity. In our +specific case here for example, 255 or the highest intensity is mapped +to yellow, and 0 or the lowest intensity is mapped to a dark purple. The +best colour map for your data will vary and there are many options built +in, but this default selection was not arbitrary. A lot of science went +into making this the default due to its robustness when it comes to how +the human mind interprets relative colour values, grey-scale +printability, and colour-blind friendliness (You can read more about +this default colour map in a +Matplotlib tutorial and an explanatory article by the +authors). Thus it is a good place to start, and you should change it +only with purpose and forethought. For now, let’s see how you can do +that using an alternative map you have likely seen before where it will +be even easier to see it as a mapped continuum of intensities: +greyscale.

+
+

PYTHON +

+
fig, ax = plt.subplots()
+ax.imshow(three_colours, cmap="gray")
+
+
Image in greyscale

Above we have exactly the same underlying data matrix, but in +greyscale. Zero maps to black, 255 maps to white, and 128 maps to medium +grey. Here we only have a single channel in the data and utilize a +grayscale color map to represent the luminance, or intensity of the data +and correspondingly this channel is referred to as the luminance +channel.

+

Even more colours

+

This is all well and good at this scale, but what happens when we +instead have a picture of a natural landscape that contains millions of +colours. Having a one to one mapping of number to colour like this would +be inefficient and make adjustments and building tools to do so very +difficult. Rather than larger numbers, the solution is to have more +numbers in more dimensions. Storing the numbers in a multi-dimensional +matrix where each colour or property like transparency is associated +with its own dimension allows for individual contributions to a pixel to +be adjusted independently. This ability to manipulate properties of +groups of pixels separately will be key to certain techniques explored +in later chapters of this lesson. To get started let’s see an example of +how different dimensions of information combine to produce a set of +pixels using a 4 x 4 matrix with 3 dimensions for the colours red, +green, and blue. Rather than loading it from a file, we will generate +this example using NumPy.

+
+

PYTHON +

+
# set the random seed so we all get the same matrix
+pseudorandomizer = np.random.RandomState(2021)
+# create a 4 × 4 checkerboard of random colours
+checkerboard = pseudorandomizer.randint(0, 255, size=(4, 4, 3))
+# restore the default map as you show the image
+fig, ax = plt.subplots()
+ax.imshow(checkerboard)
+# display the arrays
+print(checkerboard)
+
+
+

OUTPUT +

+
[[[116  85  57]
+  [128 109  94]
+  [214  44  62]
+  [219 157  21]]
+
+ [[ 93 152 140]
+  [246 198 102]
+  [ 70  33 101]
+  [  7   1 110]]
+
+ [[225 124 229]
+  [154 194 176]
+  [227  63  49]
+  [144 178  54]]
+
+ [[123 180  93]
+  [120   5  49]
+  [166 234 142]
+  [ 71  85  70]]]
+
+
Image of checkerboard

Previously we had one number being mapped to one colour or intensity. +Now we are combining the effect of 3 numbers to arrive at a single +colour value. Let’s see an example of that using the blue square at the +end of the second row, which has the index [1, 3].

+
+

PYTHON +

+
# extract all the colour information for the blue square
+upper_right_square = checkerboard[1, 3, :]
+upper_right_square
+
+

This outputs: array([ 7, 1, 110]) The integers in order represent +Red, Green, and Blue. Looking at the 3 values and knowing how they map, +can help us understand why it is blue. If we divide each value by 255, +which is the maximum, we can determine how much it is contributing +relative to its maximum potential. Effectively, the red is at 7/255 or +2.8 percent of its potential, the green is at 1/255 or 0.4 percent, and +blue is 110/255 or 43.1 percent of its potential. So when you mix those +three intensities of colour, blue is winning by a wide margin, but the +red and green still contribute to make it a slightly different shade of +blue than 0,0,110 would be on its own.

+

These colours mapped to dimensions of the matrix may be referred to +as channels. It may be helpful to display each of these channels +independently, to help us understand what is happening. We can do that +by multiplying our image array representation with a 1d matrix that has +a one for the channel we want to keep and zeros for the rest.

+
+

PYTHON +

+
red_channel = checkerboard * [1, 0, 0]
+fig, ax = plt.subplots()
+ax.imshow(red_channel)
+
+
Image of red channel
+

PYTHON +

+
green_channel = checkerboard * [0, 1, 0]
+fig, ax = plt.subplots()
+ax.imshow(green_channel)
+
+
Image of green channel
+

PYTHON +

+
blue_channel = checkerboard * [0, 0, 1]
+fig, ax = plt.subplots()
+ax.imshow(blue_channel)
+
+
Image of blue channel

If we look at the upper [1, 3] square in all three figures, we can +see each of those colour contributions in action. Notice that there are +several squares in the blue figure that look even more intensely blue +than square [1, 3]. When all three channels are combined though, the +blue light of those squares is being diluted by the relative strength of +red and green being mixed in with them.

+

24-bit RGB colour

+

This last colour model we used, known as the RGB (Red, Green, +Blue) model, is the most common.

+

As we saw, the RGB model is an additive colour model, which +means that the primary colours are mixed together to form other colours. +Most frequently, the amount of the primary colour added is represented +as an integer in the closed range [0, 255] as seen in the example. +Therefore, there are 256 discrete amounts of each primary colour that +can be added to produce another colour. The number of discrete amounts +of each colour, 256, corresponds to the number of bits used to hold the +colour channel value, which is eight (28=256). Since we have +three channels with 8 bits for each (8+8+8=24), this is called 24-bit +colour depth.

+

Any particular colour in the RGB model can be expressed by a triplet +of integers in [0, 255], representing the red, green, and blue channels, +respectively. A larger number in a channel means that more of that +primary colour is present.

+
+
+ +
+Challenge +
+

Thinking about RGB colours (5 min)

+
+

Suppose that we represent colours as triples (r, g, b), where each of +r, g, and b is an integer in [0, 255]. What colours are represented by +each of these triples? (Try to answer these questions without reading +further.)

+
  1. (255, 0, 0)
  2. +
  3. (0, 255, 0)
  4. +
  5. (0, 0, 255)
  6. +
  7. (255, 255, 255)
  8. +
  9. (0, 0, 0)
  10. +
  11. (128, 128, 128)
  12. +
+
+
+
+
+ +
+
+
  1. (255, 0, 0) represents red, because the red channel is maximised, +while the other two channels have the minimum values.
  2. +
  3. (0, 255, 0) represents green.
  4. +
  5. (0, 0, 255) represents blue.
  6. +
  7. (255, 255, 255) is a little harder. When we mix the maximum value of +all three colour channels, we see the colour white.
  8. +
  9. (0, 0, 0) represents the absence of all colour, or black.
  10. +
  11. (128, 128, 128) represents a medium shade of gray. Note that the +24-bit RGB colour model provides at least 254 shades of gray, rather +than only fifty.
  12. +

Note that the RGB colour model may run contrary to your experience, +especially if you have mixed primary colours of paint to create new +colours. In the RGB model, the lack of any colour is black, +while the maximum amount of each of the primary colours is +white. With physical paint, we might start with a white base, and then +add differing amounts of other paints to produce a darker shade.

+
+
+
+
+

After completing the previous challenge, we can look at some further +examples of 24-bit RGB colours, in a visual way. The image in the next +challenge shows some colour names, their 24-bit RGB triplet values, and +the colour itself.

+
+
+ +
+Challenge +
+

RGB colour table (optional, not included in +timing)

+
+
RGB colour table

We cannot really provide a complete table. To see why, answer this +question: How many possible colours can be represented with the 24-bit +RGB model?

+
+
+
+
+
+ +
+
+

There are 24 total bits in an RGB colour of this type, and each bit +can be on or off, and so there are 224 = 16,777,216 possible +colours with our additive, 24-bit RGB colour model.

+
+
+
+
+

Although 24-bit colour depth is common, there are other options. For +example, we might have 8-bit colour (3 bits for red and green, but only +2 for blue, providing 8 × 8 × 4 = 256 colours) or 16-bit colour (4 bits +for red, green, and blue, plus 4 more for transparency, providing 16 × +16 × 16 = 4096 colours, with 16 transparency levels each). There are +colour depths with more than eight bits per channel, but as the human +eye can only discern approximately 10 million different colours, these +are not often used.

+

If you are using an older or inexpensive laptop screen or LCD monitor +to view images, it may only support 18-bit colour, capable of displaying +64 × 64 × 64 = 262,144 colours. 24-bit colour images will be converted +in some manner to 18-bit, and thus the colour quality you see will not +match what is actually in the image.

+

We can combine our coordinate system with the 24-bit RGB colour model +to gain a conceptual understanding of the images we will be working +with. An image is a rectangular array of pixels, each with its own +coordinate. Each pixel in the image is a square point of coloured light, +where the colour is specified by a 24-bit RGB triplet. Such an image is +an example of raster graphics.

+

Image formats

+

Although the images we will manipulate in our programs are +conceptualised as rectangular arrays of RGB triplets, they are not +necessarily created, stored, or transmitted in that format. There are +several image formats we might encounter, and we should know the basics +of at least of few of them. Some formats we might encounter, and their +file extensions, are shown in this table:

+ + + + + + + + +
FormatExtension
Device-Independent Bitmap (BMP).bmp
Joint Photographic Experts Group (JPEG).jpg or .jpeg
Tagged Image File Format (TIFF).tif or .tiff

BMP

+

The file format that comes closest to our preceding conceptualisation +of images is the Device-Independent Bitmap, or BMP, file format. BMP +files store raster graphics images as long sequences of binary-encoded +numbers that specify the colour of each pixel in the image. Since +computer files are one-dimensional structures, the pixel colours are +stored one row at a time. That is, the first row of pixels (those with +y-coordinate 0) are stored first, followed by the second row (those with +y-coordinate 1), and so on. Depending on how it was created, a BMP image +might have 8-bit, 16-bit, or 24-bit colour depth.

+

24-bit BMP images have a relatively simple file format, can be viewed +and loaded across a wide variety of operating systems, and have high +quality. However, BMP images are not compressed, resulting in +very large file sizes for any useful image resolutions.

+

The idea of image compression is important to us for two reasons: +first, compressed images have smaller file sizes, and are therefore +easier to store and transmit; and second, compressed images may not have +as much detail as their uncompressed counterparts, and so our programs +may not be able to detect some important aspect if we are working with +compressed images. Since compression is important to us, we should take +a brief detour and discuss the concept.

+

Image compression

+

Before discussing additional formats, familiarity with image +compression will be helpful. Let’s delve into that subject with a +challenge. For this challenge, you will need to know about bits / bytes +and how those are used to express computer storage capacities. If you +already know, you can skip to the challenge below.

+
+
+ +
+Callout +
+

Bits and bytes

+
+

Before we talk specifically about images, we first need to understand +how numbers are stored in a modern digital computer. When we think of a +number, we do so using a decimal, or base-10 +place-value number system. For example, a number like 659 is 6 × +102 + 5 × 101 + 9 × 100. Each digit in +the number is multiplied by a power of 10, based on where it occurs, and +there are 10 digits that can occur in each position (0, 1, 2, 3, 4, 5, +6, 7, 8, 9).

+

In principle, computers could be constructed to represent numbers in +exactly the same way. But, the electronic circuits inside a computer are +much easier to construct if we restrict the numeric base to only two, +instead of 10. (It is easier for circuitry to tell the difference +between two voltage levels than it is to differentiate among 10 levels.) +So, values in a computer are stored using a binary, or +base-2 place-value number system.

+

In this system, each symbol in a number is called a bit +instead of a digit, and there are only two values for each bit (0 and +1). We might imagine a four-bit binary number, 1101. Using the same kind +of place-value expansion as we did above for 659, we see that 1101 = 1 × +23 + 1 × 22 + 0 × 21 + 1 × +20, which if we do the math is 8 + 4 + 0 + 1, or 13 in +decimal.

+

Internally, computers have a minimum number of bits that they work +with at a given time: eight. A group of eight bits is called a +byte. The amount of memory (RAM) and drive space our computers +have is quantified by terms like Megabytes (MB), Gigabytes (GB), and +Terabytes (TB). The following table provides more formal definitions for +these terms.

+ + + + + + + + + + + + + + + +
UnitAbbreviationSize
KilobyteKB1024 bytes
MegabyteMB1024 KB
GigabyteGB1024 MB
TerabyteTB1024 GB
+
+
+
+
+ +
+Challenge +
+

BMP image size (optional, not included in +timing)

+
+

Imagine that we have a fairly large, but very boring image: a 5,000 × +5,000 pixel image composed of nothing but white pixels. If we used an +uncompressed image format such as BMP, with the 24-bit RGB colour model, +how much storage would be required for the file?

+
+
+
+
+
+ +
+
+

In such an image, there are 5,000 × 5,000 = 25,000,000 pixels, and 24 +bits for each pixel, leading to 25,000,000 × 24 = 600,000,000 bits, or +75,000,000 bytes (71.5MB). That is quite a lot of space for a very +uninteresting image!

+
+
+
+
+

Since image files can be very large, various compression +schemes exist for saving (approximately) the same information while +using less space. These compression techniques can be categorised as +lossless or lossy.

+
+

Lossless compression

+

In lossless image compression, we apply some algorithm (i.e., a +computerised procedure) to the image, resulting in a file that is +significantly smaller than the uncompressed BMP file equivalent would +be. Then, when we wish to load and view or process the image, our +program reads the compressed file, and reverses the compression process, +resulting in an image that is identical to the original. +Nothing is lost in the process – hence the term “lossless.”

+

The general idea of lossless compression is to somehow detect long +patterns of bytes in a file that are repeated over and over, and then +assign a smaller bit pattern to represent the longer sample. Then, the +compressed file is made up of the smaller patterns, rather than the +larger ones, thus reducing the number of bytes required to save the +file. The compressed file also contains a table of the substituted +patterns and the originals, so when the file is decompressed it can be +made identical to the original before compression.

+

To provide you with a concrete example, consider the 71.5 MB white +BMP image discussed above. When put through the zip compression utility +on Microsoft Windows, the resulting .zip file is only 72 KB in size! +That is, the .zip version of the image is three orders of magnitude +smaller than the original, and it can be decompressed into a file that +is byte-for-byte the same as the original. Since the original is so +repetitious - simply the same colour triplet repeated 25,000,000 times - +the compression algorithm can dramatically reduce the size of the +file.

+

If you work with .zip or .gz archives, you are dealing with lossless +compression.

+
+
+

Lossy compression

+

Lossy compression takes the original image and discards some of the +detail in it, resulting in a smaller file format. The goal is to only +throw away detail that someone viewing the image would not notice. Many +lossy compression schemes have adjustable levels of compression, so that +the image creator can choose the amount of detail that is lost. The more +detail that is sacrificed, the smaller the image files will be - but of +course, the detail and richness of the image will be lower as well.

+

This is probably fine for images that are shown on Web pages or +printed off on 4 × 6 photo paper, but may or may not be fine for +scientific work. You will have to decide whether the loss of image +quality and detail are important to your work, versus the space savings +afforded by a lossy compression format.

+

It is important to understand that once an image is saved in a lossy +compression format, the lost detail is just that - lost. I.e., unlike +lossless formats, given an image saved in a lossy format, there is no +way to reconstruct the original image in a byte-by-byte manner.

+
+

JPEG

+

JPEG images are perhaps the most commonly encountered digital images +today. JPEG uses lossy compression, and the degree of compression can be +tuned to your liking. It supports 24-bit colour depth, and since the +format is so widely used, JPEG images can be viewed and manipulated +easily on all computing platforms.

+
+
+ +
+Challenge +
+

Examining actual image sizes (optional, not +included in timing)

+
+

Let us see the effects of image compression on image size with actual +images. The following script creates a square white image 5000 x 5000 +pixels, and then saves it as a BMP and as a JPEG image.

+
+

PYTHON +

+
dim = 5000
+
+img = np.zeros((dim, dim, 3), dtype="uint8")
+img.fill(255)
+
+iio.imwrite(uri="data/ws.bmp", image=img)
+iio.imwrite(uri="data/ws.jpg", image=img)
+
+

Examine the file sizes of the two output files, ws.bmp +and ws.jpg. Does the BMP image size match our previous +prediction? How about the JPEG?

+
+
+
+
+
+ +
+
+

The BMP file, ws.bmp, is 75,000,054 bytes, which matches +our prediction very nicely. The JPEG file, ws.jpg, is +392,503 bytes, two orders of magnitude smaller than the bitmap +version.

+
+
+
+
+
+
+ +
+Challenge +
+

Comparing lossless versus lossy compression +(optional, not included in timing)

+
+

Let us see a hands-on example of lossless versus lossy compression. +Open a terminal (or Windows PowerShell) and navigate to the +data/ directory. The two output images, ws.bmp +and ws.jpg, should still be in the directory, along with +another image, tree.jpg.

+

We can apply lossless compression to any file by using the +zip command. Recall that the ws.bmp file +contains 75,000,054 bytes. Apply lossless compression to this image by +executing the following command: zip ws.zip ws.bmp +(Compress-Archive ws.bmp ws.zip with PowerShell). This +command tells the computer to create a new compressed file, +ws.zip, from the original bitmap image. Execute a similar +command on the tree JPEG file: zip tree.zip tree.jpg +(Compress-Archive tree.jpg tree.zip with PowerShell).

+

Having created the compressed file, use the ls -l +command (dir with PowerShell) to display the contents of +the directory. How big are the compressed files? How do those compare to +the size of ws.bmp and tree.jpg? What can you +conclude from the relative sizes?

+
+
+
+
+
+ +
+
+

Here is a partial directory listing, showing the sizes of the +relevant files there:

+
+

OUTPUT +

+
-rw-rw-r--  1 diva diva   154344 Jun 18 08:32 tree.jpg
+-rw-rw-r--  1 diva diva   146049 Jun 18 08:53 tree.zip
+-rw-rw-r--  1 diva diva 75000054 Jun 18 08:51 ws.bmp
+-rw-rw-r--  1 diva diva    72986 Jun 18 08:53 ws.zip
+
+

We can see that the regularity of the bitmap image (remember, it is a +5,000 x 5,000 pixel image containing only white pixels) allows the +lossless compression scheme to compress the file quite effectively. On +the other hand, compressing tree.jpg does not create a much +smaller file; this is because the JPEG image was already in a compressed +format.

+
+
+
+
+

Here is an example showing how JPEG compression might impact image +quality. Consider this image of several maize seedlings (scaled down +here from 11,339 × 11,336 pixels in order to fit the display).

+
Original image

Now, let us zoom in and look at a small section of the label in the +original, first in the uncompressed format:

+
Enlarged, uncompressed

Here is the same area of the image, but in JPEG format. We used a +fairly aggressive compression parameter to make the JPEG, in order to +illustrate the problems you might encounter with the format.

+
Enlarged, compressed

The JPEG image is of clearly inferior quality. It has less colour +variation and noticeable pixelation. Quality differences become even +more marked when one examines the colour histograms for each image. A +histogram shows how often each colour value appears in an image. The +histograms for the uncompressed (left) and compressed (right) images are +shown below:

+
Uncompressed histogram

We learn how to make histograms such as these later on in the +workshop. The differences in the colour histograms are even more +apparent than in the images themselves; clearly the colours in the JPEG +image are different from the uncompressed version.

+

If the quality settings for your JPEG images are high (and the +compression rate therefore relatively low), the images may be of +sufficient quality for your work. It all depends on how much quality you +need, and what restrictions you have on image storage space. Another +consideration may be where the images are stored. For example, +if your images are stored in the cloud and therefore must be downloaded +to your system before you use them, you may wish to use a compressed +image format to speed up file transfer time.

+

PNG

+

PNG images are well suited for storing diagrams. It uses a lossless +compression and is hence often used in web applications for +non-photographic images. The format is able to store RGB and plain +luminance (single channel, without an associated color) data, among +others. Image data is stored row-wise and then, per row, a simple +filter, like taking the difference of adjacent pixels, can be applied to +increase the compressability of the data. The filtered data is then +compressed in the next step and written out to the disk.

+

TIFF

+

TIFF images are popular with publishers, graphics designers, and +photographers. TIFF images can be uncompressed, or compressed using +either lossless or lossy compression schemes, depending on the settings +used, and so TIFF images seem to have the benefits of both the BMP and +JPEG formats. The main disadvantage of TIFF images (other than the size +of images in the uncompressed version of the format) is that they are +not universally readable by image viewing and manipulation software.

+

Metadata

+

JPEG and TIFF images support the inclusion of metadata in +images. Metadata is textual information that is contained within an +image file. Metadata holds information about the image itself, such as +when the image was captured, where it was captured, what type of camera +was used and with what settings, etc. We normally don’t see this +metadata when we view an image, but we can view it independently if we +wish to (see Accessing +Metadata, below). The important thing to be aware of at this +stage is that you cannot rely on the metadata of an image being fully +preserved when you use software to process that image. The image +reader/writer library that we use throughout this lesson, +imageio.v3, includes metadata when saving new images but +may fail to keep certain metadata fields. In any case, remember: +if metadata is important to you, take precautions to always +preserve the original files.

+
+
+ +
+Callout +
+

Accessing Metadata

+
+

imageio.v3 provides a way to display or explore the +metadata associated with an image. Metadata is served independently from +pixel data:

+
+

PYTHON +

+
# read metadata
+metadata = iio.immeta(uri="data/eight.tif")
+# display the format-specific metadata
+metadata
+
+
+

OUTPUT +

+
{'is_fluoview': False,
+ 'is_nih': False,
+ 'is_micromanager': False,
+ 'is_ome': False,
+ 'is_lsm': False,
+ 'is_reduced': False,
+ 'is_shaped': True,
+ 'is_stk': False,
+ 'is_tiled': False,
+ 'is_mdgel': False,
+ 'compression': <COMPRESSION.NONE: 1>,
+ 'predictor': 1,
+ 'is_mediacy': False,
+ 'description': '{"shape": [5, 3]}',
+ 'description1': '',
+ 'is_imagej': False,
+ 'software': 'tifffile.py',
+ 'resolution_unit': 1,
+ 'resolution': (1.0, 1.0, 'NONE')}
+
+

Many popular image editing programs have built-in metadata viewing +capabilities. A platform-independent open-source tool that allows users +to read, write, and edit metadata is ExifTool. It can handle a wide range of +file types and metadata formats but requires some technical knowledge to +be used effectively. Other software exists that can help you handle +metadata, e.g., Fiji and ImageMagick. You may want +to explore these options if you need to work with the metadata of your +images.

+
+
+
+

Summary of image formats used in this lesson

+

The following table summarises the characteristics of the BMP, JPEG, +and TIFF image formats:

+ + + + + + + + + + + + + + + + + + + + + + + + + +
FormatCompressionMetadataAdvantagesDisadvantages
BMPNoneNoneUniversally viewable, high qualityLarge file sizes
JPEGLossyYesUniversally viewable, smaller file sizeDetail may be lost
PNGLosslessYesUniversally viewable, open standard, smaller file +sizeMetadata less flexible than TIFF, RGB only
TIFFNone, lossy, or losslessYesHigh quality or smaller file sizeNot universally viewable
+
+ +
+Key Points +
+
+
  • Digital images are represented as rectangular arrays of square +pixels.
  • +
  • Digital images use a left-hand coordinate system, with the origin in +the upper left corner, the x-axis running to the right, and the y-axis +running down. Some learners may prefer to think in terms of counting +down rows for the y-axis and across columns for the x-axis. Thus, we +will make an effort to allow for both approaches in our lesson +presentation.
  • +
  • Most frequently, digital images use an additive RGB model, with +eight bits for the red, green, and blue channels.
  • +
  • scikit-image images are stored as multi-dimensional NumPy +arrays.
  • +
  • In scikit-image images, the red channel is specified first, then the +green, then the blue, i.e., RGB.
  • +
  • Lossless compression retains all the details in an image, but lossy +compression results in loss of some of the original image detail.
  • +
  • BMP images are uncompressed, meaning they have high quality but also +that their file sizes are large.
  • +
  • JPEG images use lossy compression, meaning that their file sizes are +smaller, but image quality may suffer.
  • +
  • TIFF images can be uncompressed or compressed with lossy or lossless +compression.
  • +
  • Depending on the camera or sensor, various useful pieces of +information may be stored in an image file, in the image metadata.
  • +
+
+
+
+
+ + +
+
+ + + diff --git a/instructor/03-skimage-images.html b/instructor/03-skimage-images.html new file mode 100644 index 000000000..b9efda690 --- /dev/null +++ b/instructor/03-skimage-images.html @@ -0,0 +1,1102 @@ + +Image Processing with Python: Working with scikit-image +
+
+ + + + + +
+
+

Working with scikit-image

+

Last updated on 2026-03-20 | + + Edit this page

+ + + +

Estimated time: 120 minutes

+ +
+ +
+ + + +
+

Overview

+
+
+
+
+

Questions

+
  • How can the scikit-image Python computer vision library be used to +work with images?
  • +
+
+
+
+
+
+

Objectives

+
  • Read and save images with imageio.
  • +
  • Display images with Matplotlib.
  • +
  • Resize images with scikit-image.
  • +
  • Perform simple image thresholding with NumPy array operations.
  • +
  • Extract sub-images using array slicing.
  • +
+
+
+
+
+

We have covered much of how images are represented in computer +software. In this episode we will learn some more methods for accessing +and changing digital images.

+

First, import the packages needed for this episode

+
+

PYTHON +

+
import imageio.v3 as iio
+import ipympl
+import matplotlib.pyplot as plt
+import numpy as np
+import skimage as ski
+
+%matplotlib widget
+
+

Reading, displaying, and saving images

+

Imageio provides intuitive functions for reading and writing (saving) +images. All of the popular image formats, such as BMP, PNG, JPEG, and +TIFF are supported, along with several more esoteric formats. Check the +Supported +Formats docs for a list of all formats. Matplotlib provides a large +collection of plotting utilities.

+

Let us examine a simple Python program to load, display, and save an +image to a different format. Here are the first few lines:

+
+

PYTHON +

+
"""Python program to open, display, and save an image."""
+# read image
+chair = iio.imread(uri="data/chair.jpg")
+
+

We use the iio.imread() function to read a JPEG image +entitled chair.jpg. Imageio reads the image, converts +it from JPEG into a NumPy array, and returns the array; we save the +array in a variable named chair.

+

Next, we will do something with the image:

+
+

PYTHON +

+
fig, ax = plt.subplots()
+ax.imshow(chair)
+
+

Once we have the image in the program, we first call +fig, ax = plt.subplots() so that we will have a fresh +figure with a set of axes independent from our previous calls. Next we +call ax.imshow() in order to display the image.

+

Now, we will save the image in another format:

+
+

PYTHON +

+
# save a new version in .tif format
+iio.imwrite(uri="data/chair.tif", image=chair)
+
+

The final statement in the program, +iio.imwrite(uri="data/chair.tif", image=chair), writes the +image to a file named chair.tif in the data/ +directory. The imwrite() function automatically determines +the type of the file, based on the file extension we provide. In this +case, the .tif extension causes the image to be saved as a +TIFF.

+
+
+ +
+Callout +
+

Metadata, revisited

+
+

Remember, as mentioned in the previous section, images saved with +imwrite() will not retain all metadata associated with the +original image that was loaded into Python! If the image metadata +is important to you, be sure to always keep an unchanged copy of +the original image!

+
+
+
+
+
+ +
+Callout +
+

Extensions do not always dictate file +type

+
+

The iio.imwrite() function automatically uses the file +type we specify in the file name parameter’s extension. Note that this +is not always the case. For example, if we are editing a document in +Microsoft Word, and we save the document as paper.pdf +instead of paper.docx, the file is not saved as a +PDF document.

+
+
+
+
+
+ +
+Callout +
+

Named versus positional arguments

+
+

When we call functions in Python, there are two ways we can specify +the necessary arguments. We can specify the arguments +positionally, i.e., in the order the parameters appear in the +function definition, or we can use named arguments.

+

For example, the iio.imwrite() function +definition specifies two parameters, the resource to save the image +to (e.g., a file name, an http address) and the image to write to disk. +So, we could save the chair image in the sample code above using +positional arguments like this:

+

iio.imwrite("data/chair.tif", image)

+

Since the function expects the first argument to be the file name, +there is no confusion about what "data/chair.jpg" means. +The same goes for the second argument.

+

The style we will use in this workshop is to name each argument, like +this:

+

iio.imwrite(uri="data/chair.tif", image=image)

+

This style will make it easier for you to learn how to use the +variety of functions we will cover in this workshop.

+
+
+
+
+
+ +
+Challenge +
+

Resizing an image (10 min)

+
+

Using the chair.jpg image located in the data folder, +write a Python script to read your image into a variable named +chair. Then, resize the image to 10 percent of its current +size using these lines of code:

+
+

PYTHON +

+
new_shape = (chair.shape[0] // 10, chair.shape[1] // 10, chair.shape[2])
+resized_chair = ski.transform.resize(image=chair, output_shape=new_shape)
+resized_chair = ski.util.img_as_ubyte(resized_chair)
+
+

As it is used here, the parameters to the +ski.transform.resize() function are the image to transform, +chair, the dimensions we want the new image to have, +new_shape.

+
+
+ +
+Callout +
+
+

Note that the pixel values in the new image are an approximation of +the original values and should not be confused with actual, observed +data. This is because scikit-image interpolates the pixel values when +reducing or increasing the size of an image. +ski.transform.resize has a number of optional parameters +that allow the user to control this interpolation. You can find more +details in the scikit-image +documentation.

+
+
+
+

Image files on disk are normally stored as whole numbers for space +efficiency, but transformations and other math operations often result +in conversion to floating point numbers. Using the +ski.util.img_as_ubyte() method converts it back to whole +numbers before we save it back to disk. If we don’t convert it before +saving, iio.imwrite() may not recognise it as image +data.

+

Next, write the resized image out to a new file named +resized.jpg in your data directory. Finally, use +ax.imshow() with each of your image variables to display +both images in your notebook. Don’t forget to use +fig, ax = plt.subplots() so you don’t overwrite the first +image with the second. Images may appear the same size in jupyter, but +you can see the size difference by comparing the scales for each. You +can also see the difference in file storage size on disk by hovering +your mouse cursor over the original and the new files in the Jupyter +file browser, using ls -l in your shell (dir +with Windows PowerShell), or viewing file sizes in the OS file browser +if it is configured so.

+
+
+
+
+
+ +
+
+

Here is what your Python script might look like.

+
+

PYTHON +

+
"""Python script to read an image, resize it, and save it under a different name."""
+
+# read in image
+chair = iio.imread(uri="data/chair.jpg")
+
+# resize the image
+new_shape = (chair.shape[0] // 10, chair.shape[1] // 10, chair.shape[2])
+resized_chair = ski.transform.resize(image=chair, output_shape=new_shape)
+resized_chair = ski.util.img_as_ubyte(resized_chair)
+
+# write out image
+iio.imwrite(uri="data/resized_chair.jpg", image=resized_chair)
+
+# display images
+fig, ax = plt.subplots()
+ax.imshow(chair)
+fig, ax = plt.subplots()
+ax.imshow(resized_chair)
+
+

The script resizes the data/chair.jpg image by a factor +of 10 in both dimensions, saves the result to the +data/resized_chair.jpg file, and displays original and +resized for comparision.

+
+
+
+
+

Manipulating pixels

+

In the Image Basics +episode, we individually manipulated the colours of pixels by +changing the numbers stored in the image’s NumPy array. Let’s apply the +principles learned there along with some new principles to a real world +example.

+

Suppose we are interested in this maize root cluster image. We want +to be able to focus our program’s attention on the roots themselves, +while ignoring the black background.

+
Root cluster image

Since the image is stored as an array of numbers, we can simply look +through the array for pixel colour values that are less than some +threshold value. This process is called thresholding, and we +will see more powerful methods to perform the thresholding task in the Thresholding episode. Here, +though, we will look at a simple and elegant NumPy method for +thresholding. Let us develop a program that keeps only the pixel colour +values in an image that have value greater than or equal to 128. This +will keep the pixels that are brighter than half of “full brightness”, +i.e., pixels that do not belong to the black background.

+

We will start by reading the image and displaying it.

+
+
+ +
+Callout +
+

Loading images with imageio: Read-only +arrays

+
+

When loading an image with imageio, in certain situations the image +is stored in a read-only array. If you attempt to manipulate the pixels +in a read-only array, you will receive an error message +ValueError: assignment destination is read-only. In order +to make the image array writeable, we can create a copy with +image = np.array(image) before manipulating the pixel +values.

+
+
+
+
+

PYTHON +

+
"""Python script to ignore low intensity pixels in an image."""
+
+# read input image
+maize_roots = iio.imread(uri="data/maize-root-cluster.jpg")
+maize_roots = np.array(maize_roots)
+
+# display original image
+fig, ax = plt.subplots()
+ax.imshow(maize_roots)
+
+

Now we can threshold the image and display the result.

+
+

PYTHON +

+
# keep only high-intensity pixels
+maize_roots[maize_roots < 128] = 0
+
+# display modified image
+fig, ax = plt.subplots()
+ax.imshow(maize_roots)
+
+

The NumPy command to ignore all low-intensity pixels is +roots[roots < 128] = 0. Every pixel colour value in the +whole 3-dimensional array with a value less that 128 is set to zero. In +this case, the result is an image in which the extraneous background +detail has been removed.

+
Thresholded root image

Converting colour images to grayscale

+

It is often easier to work with grayscale images, which have a single +channel, instead of colour images, which have three channels. +scikit-image offers the function ski.color.rgb2gray() to +achieve this. This function adds up the three colour channels in a way +that matches human colour perception, see the +scikit-image documentation for details. It returns a grayscale image +with floating point values in the range from 0 to 1. We can use the +function ski.util.img_as_ubyte() in order to convert it +back to the original data type and the data range back 0 to 255. Note +that it is often better to use image values represented by floating +point values, because using floating point numbers is numerically more +stable.

+
+
+ +
+Callout +
+

Colour and color +

+
+

The Carpentries generally prefers UK English spelling, which is why +we use “colour” in the explanatory text of this lesson. However, +scikit-image contains many modules and functions that include the US +English spelling, color. The exact spelling matters here, +e.g. you will encounter an error if you try to run +ski.colour.rgb2gray(). To account for this, we will use the +US English spelling, color, in example Python code +throughout the lesson. You will encounter a similar approach with +“centre” and center.

+
+
+
+
+

PYTHON +

+
"""Python script to load a color image as grayscale."""
+
+# read input image
+chair = iio.imread(uri="data/chair.jpg")
+
+# display original image
+fig, ax = plt.subplots()
+ax.imshow(chair)
+
+# convert to grayscale and display
+gray_chair = ski.color.rgb2gray(chair)
+fig, ax = plt.subplots()
+ax.imshow(gray_chair, cmap="gray")
+
+

We can also load colour images as grayscale directly by passing the +argument mode="L" to iio.imread().

+
+

PYTHON +

+
"""Python script to load a color image as grayscale."""
+
+# read input image, based on filename parameter
+gray_chair = iio.imread(uri="data/chair.jpg", mode="L")
+
+# display grayscale image
+fig, ax = plt.subplots()
+ax.imshow(gray_chair, cmap="gray")
+
+

The first argument to iio.imread() is the filename of +the image. The second argument mode="L" determines the type +and range of the pixel values in the image (e.g., an 8-bit pixel has a +range of 0-255). This argument is forwarded to the pillow +backend, a Python imaging library for which mode “L” means 8-bit pixels +and single-channel (i.e., grayscale). The backend used by +iio.imread() may be specified as an optional argument: to +use pillow, you would pass plugin="pillow". If +the backend is not specified explicitly, iio.imread() +determines the backend to use based on the image type.

+
+
+ +
+Callout +
+

Loading images with imageio: Pixel type and +depth

+
+

When loading an image with mode="L", the pixel values +are stored as 8-bit integer numbers that can take values in the range +0-255. However, pixel values may also be stored with other types and +ranges. For example, some scikit-image functions return the pixel values +as floating point numbers in the range 0-1. The type and range of the +pixel values are important for the colorscale when plotting, and for +masking and thresholding images as we will see later in the lesson. If +you are unsure about the type of the pixel values, you can inspect it +with print(image.dtype). For the example above, you should +find that it is dtype('uint8') indicating 8-bit integer +numbers.

+
+
+
+
+
+ +
+Challenge +
+

Keeping only low intensity pixels (10 +min)

+
+

A little earlier, we showed how we could use Python and scikit-image +to turn on only the high intensity pixels from an image, while turning +all the low intensity pixels off. Now, you can practice doing the +opposite - keeping all the low intensity pixels while changing the high +intensity ones.

+

The file data/sudoku.png is an RGB image of a sudoku +puzzle:

+
Su-Do-Ku puzzle

Your task is to load the image in grayscale format and turn all of +the bright pixels in the image to a light gray colour. In other words, +mask the bright pixels that have a pixel value greater than, say, 192 +and set their value to 192 (the value 192 is chosen here because it +corresponds to 75% of the range 0-255 of an 8-bit pixel). The results +should look like this:

+
Modified Su-Do-Ku puzzle

Hint: the cmap, vmin, and +vmax parameters of matplotlib.pyplot.imshow +will be needed to display the modified image as desired. See the Matplotlib +documentation for more details on cmap, +vmin, and vmax.

+
+
+
+
+
+ +
+
+

First, load the image file data/sudoku.png as a +grayscale image. Note we may want to create a copy of the image array to +avoid modifying our original variable and also because +imageio.v3.imread sometimes returns a non-writeable +image.

+
+

PYTHON +

+
sudoku = iio.imread(uri="data/sudoku.png", mode="L")
+sudoku_gray_background = np.array(sudoku)
+
+

Then change all bright pixel values greater than 192 to 192:

+
+

PYTHON +

+
sudoku_gray_background[sudoku_gray_background > 192] = 192
+
+

Finally, display the original and modified images side by side. Note +that we have to specify vmin=0 and vmax=255 as +the range of the colorscale because it would otherwise automatically +adjust to the new range 0-192.

+
+

PYTHON +

+
fig, ax = plt.subplots(ncols=2)
+ax[0].imshow(sudoku, cmap="gray", vmin=0, vmax=255)
+ax[1].imshow(sudoku_gray_background, cmap="gray", vmin=0, vmax=255)
+
+
+
+
+
+
+
+ +
+Callout +
+

Plotting single channel images (cmap, vmin, +vmax)

+
+

Compared to a colour image, a grayscale image contains only a single +intensity value per pixel. When we plot such an image with +ax.imshow, Matplotlib uses a colour map, to assign each +intensity value a colour. The default colour map is called “viridis” and +maps low values to purple and high values to yellow. We can instruct +Matplotlib to map low values to black and high values to white instead, +by calling ax.imshow with cmap="gray". The +documentation contains an overview of pre-defined colour maps.

+

Furthermore, Matplotlib determines the minimum and maximum values of +the colour map dynamically from the image, by default. That means that +in an image where the minimum is 64 and the maximum is 192, those values +will be mapped to black and white respectively (and not dark gray and +light gray as you might expect). If there are defined minimum and +maximum vales, you can specify them via vmin and +vmax to get the desired output.

+

If you forget about this, it can lead to unexpected results. Try +removing the vmax parameter from the sudoku challenge +solution and see what happens.

+
+
+
+

Access via slicing

+

As noted in the previous lesson scikit-image images are stored as +NumPy arrays, so we can use array slicing to select rectangular areas of +an image. Then, we can save the selection as a new image, change the +pixels in the image, and so on. It is important to remember that +coordinates are specified in (ry, cx) order and that colour +values are specified in (r, g, b) order when doing these +manipulations.

+

Consider this image of a whiteboard, and suppose that we want to +create a sub-image with just the portion that says “odd + even = odd,” +along with the red box that is drawn around the words.

+
Whiteboard image

Using matplotlib.pyplot.imshow we can determine the +coordinates of the corners of the area we wish to extract by hovering +the mouse near the points of interest and noting the coordinates +(remember to run %matplotlib widget first if you haven’t +already). If we do that, we might settle on a rectangular area with an +upper-left coordinate of (135, 60) and a lower-right coordinate +of (480, 150), as shown in this version of the whiteboard +picture:

+
Whiteboard coordinates

Note that the coordinates in the preceding image are specified in +(cx, ry) order. Now if our entire whiteboard image is stored as +a NumPy array named image, we can create a new image of the +selected region with a statement like this:

+

clip = image[60:151, 135:481, :]

+

Our array slicing specifies the range of y-coordinates or rows first, +60:151, and then the range of x-coordinates or columns, +135:481. Note we go one beyond the maximum value in each +dimension, so that the entire desired area is selected. The third part +of the slice, :, indicates that we want all three colour +channels in our new image.

+

A script to create the subimage would start by loading the image:

+
+

PYTHON +

+
"""Python script demonstrating image modification and creation via NumPy array slicing."""
+
+# load and display original image
+board = iio.imread(uri="data/board.jpg")
+board = np.array(board)
+fig, ax = plt.subplots()
+ax.imshow(board)
+
+

Then we use array slicing to create a new image with our selected +area and then display the new image.

+
+

PYTHON +

+
# extract, display, and save sub-image
+clipped_board = board[60:151, 135:481, :]
+fig, ax = plt.subplots()
+ax.imshow(clipped_board)
+iio.imwrite(uri="data/clipped_board.tif", image=clipped_board)
+
+

We can also change the values in an image, as shown next.

+
+

PYTHON +

+
# replace clipped area with sampled color
+color = board[330, 90]
+board[60:151, 135:481] = color
+fig, ax = plt.subplots()
+ax.imshow(board)
+
+

First, we sample a single pixel’s colour at a particular location of +the image, saving it in a variable named color, which +creates a 1 × 1 × 3 NumPy array with the blue, green, and red colour +values for the pixel located at (ry = 330, cx = 90). Then, with +the img[60:151, 135:481] = color command, we modify the +image in the specified area. From a NumPy perspective, this changes all +the pixel values within that range to array saved in the +color variable. In this case, the command “erases” that +area of the whiteboard, replacing the words with a beige colour, as +shown in the final image produced by the program:

+
"Erased" whiteboard
+
+ +
+Challenge +
+

Practicing with slices (10 min - optional, not +included in timing)

+
+

Using the techniques you just learned, write a script that creates, +displays, and saves a sub-image containing only the plant and its roots +from “data/maize-root-cluster.jpg”

+
+
+
+
+
+ +
+
+

Here is the completed Python program to select only the plant and +roots in the image.

+
+

PYTHON +

+
"""Python script to extract a sub-image containing only the plant and roots in an existing image."""
+
+# load and display original image
+maize_roots = iio.imread(uri="data/maize-root-cluster.jpg")
+fig, ax = plt.subplots()
+ax.imshow(maize_roots)
+
+# extract and display sub-image
+clipped_maize = maize_roots[0:400, 275:550, :]
+fig, ax = plt.subplots()
+ax.imshow(clipped_maize)
+
+
+# save sub-image
+iio.imwrite(uri="data/clipped_maize.jpg", image=clipped_maize)
+
+
+
+
+
+
+
+ +
+Key Points +
+
+
  • Images are read from disk with the iio.imread() +function.
  • +
  • We create a window that automatically scales the displayed image +with Matplotlib and calling imshow() on the global figure +object.
  • +
  • Colour images can be transformed to grayscale using +ski.color.rgb2gray() or, in many cases, be read as +grayscale directly by passing the argument mode="L" to +iio.imread().
  • +
  • We can resize images with the ski.transform.resize() +function.
  • +
  • NumPy array commands, such as +image[image < 128] = 0, can be used to manipulate the +pixels of an image.
  • +
  • Array slicing can be used to extract sub-images or modify areas of +images, e.g., clip = image[60:150, 135:480, :].
  • +
  • Metadata is not retained when images are loaded as NumPy arrays +using iio.imread().
  • +
+
+
+
+
+ + +
+
+ + + diff --git a/instructor/04-drawing.html b/instructor/04-drawing.html new file mode 100644 index 000000000..686a82844 --- /dev/null +++ b/instructor/04-drawing.html @@ -0,0 +1,1054 @@ + +Image Processing with Python: Drawing and Bitwise Operations +
+
+ + + + + +
+
+

Drawing and Bitwise Operations

+

Last updated on 2026-03-20 | + + Edit this page

+ + + +

Estimated time: 90 minutes

+ +
+ +
+ + + +
+

Overview

+
+
+
+
+

Questions

+
  • How can we draw on scikit-image images and use bitwise operations +and masks to select certain parts of an image?
  • +
+
+
+
+
+
+

Objectives

+
  • Create a blank, black scikit-image image.
  • +
  • Draw rectangles and other shapes on scikit-image images.
  • +
  • Explain how a white shape on a black background can be used as a +mask to select specific parts of an image.
  • +
  • Use bitwise operations to apply a mask to an image.
  • +
+
+
+
+
+

The next series of episodes covers a basic toolkit of scikit-image +operators. With these tools, we will be able to create programs to +perform simple analyses of images based on changes in colour or +shape.

+

First, import the packages needed for this episode

+
+

PYTHON +

+
import imageio.v3 as iio
+import ipympl
+import matplotlib.pyplot as plt
+import numpy as np
+import skimage as ski
+
+%matplotlib widget
+
+

Here, we import the same packages as earlier in the lesson.

+

Drawing on images

+

Often we wish to select only a portion of an image to analyze, and +ignore the rest. Creating a rectangular sub-image with slicing, as we +did in the Working with +scikit-image episode is one option for simple cases. Another +option is to create another special image, of the same size as the +original, with white pixels indicating the region to save and black +pixels everywhere else. Such an image is called a mask. In +preparing a mask, we sometimes need to be able to draw a shape - a +circle or a rectangle, say - on a black image. scikit-image provides +tools to do that.

+

Consider this image of maize seedlings:

+
Maize seedlings

Now, suppose we want to analyze only the area of the image containing +the roots themselves; we do not care to look at the kernels, or anything +else about the plants. Further, we wish to exclude the frame of the +container holding the seedlings as well. Hovering over the image with +our mouse, could tell us that the upper-left coordinate of the sub-area +we are interested in is (44, 357), while the lower-right +coordinate is (720, 740). These coordinates are shown in +(x, y) order.

+

A Python program to create a mask to select only that area of the +image would start with a now-familiar section of code to open and +display the original image:

+
+

PYTHON +

+
# Load and display the original image
+maize_seedlings = iio.imread(uri="data/maize-seedlings.tif")
+
+fig, ax = plt.subplots()
+ax.imshow(maize_seedlings)
+
+

We load and display the initial image in the same way we have done +before.

+

NumPy allows indexing of images/arrays with “boolean” arrays of the +same size. Indexing with a boolean array is also called mask indexing. +The “pixels” in such a mask array can only take two values: +True or False. When indexing an image with +such a mask, only pixel values at positions where the mask is +True are accessed. But first, we need to generate a mask +array of the same size as the image. Luckily, the NumPy library provides +a function to create just such an array. The next section of code shows +how:

+
+

PYTHON +

+
# Create the basic mask
+mask = np.ones(shape=maize_seedlings.shape[0:2], dtype="bool")
+
+

The first argument to the ones() function is the shape +of the original image, so that our mask will be exactly the same size as +the original. Notice, that we have only used the first two indices of +our shape. We omitted the channel dimension. Indexing with such a mask +will change all channel values simultaneously. The second argument, +dtype = "bool", indicates that the elements in the array +should be booleans - i.e., values are either True or +False. Thus, even though we use np.ones() to +create the mask, its pixel values are in fact not 1 but +True. You could check this, e.g., by +print(mask[0, 0]).

+

Next, we draw a filled, rectangle on the mask:

+
+

PYTHON +

+
# Draw filled rectangle on the mask image
+rr, cc = ski.draw.rectangle(start=(357, 44), end=(740, 720))
+mask[rr, cc] = False
+
+# Display mask image
+fig, ax = plt.subplots()
+ax.imshow(mask, cmap="gray")
+
+

Here is what our constructed mask looks like: Maize image mask

+

The parameters of the rectangle() function +(357, 44) and (740, 720), are the coordinates +of the upper-left (start) and lower-right +(end) corners of a rectangle in (ry, cx) order. +The function returns the rectangle as row (rr) and column +(cc) coordinate arrays.

+
+
+ +
+Callout +
+

Check the documentation!

+
+

When using an scikit-image function for the first time - or the fifth +time - it is wise to check how the function is used, via the scikit-image +documentation or other usage examples on programming-related sites +such as Stack Overflow. Basic +information about scikit-image functions can be found interactively in +Python, via commands like help(ski) or +help(ski.draw.rectangle). Take notes in your lab notebook. +And, it is always wise to run some test code to verify that the +functions your program uses are behaving in the manner you intend.

+
+
+
+
+
+ +
+Callout +
+

Variable naming conventions!

+
+

You may have wondered why we called the return values of the +rectangle function rr and cc?! You may have +guessed that r is short for row and +c is short for column. However, the rectangle +function returns mutiple rows and columns; thus we used a convention of +doubling the letter r to rr (and +c to cc) to indicate that those are multiple +values. In fact it may have even been clearer to name those variables +rows and columns; however this would have been +also much longer. Whatever you decide to do, try to stick to some +already existing conventions, such that it is easier for other people to +understand your code.

+
+
+
+
+
+ +
+Challenge +
+

Other drawing operations (15 min)

+
+

There are other functions for drawing on images, in addition to the +ski.draw.rectangle() function. We can draw circles, lines, +text, and other shapes as well. These drawing functions may be useful +later on, to help annotate images that our programs produce. Practice +some of these functions here.

+

Circles can be drawn with the ski.draw.disk() function, +which takes two parameters: the (ry, cx) point of the centre of the +circle, and the radius of the circle. There is an optional +shape parameter that can be supplied to this function. It +will limit the output coordinates for cases where the circle dimensions +exceed the ones of the image.

+

Lines can be drawn with the ski.draw.line() function, +which takes four parameters: the (ry, cx) coordinate of one end of the +line, and the (ry, cx) coordinate of the other end of the line.

+

Other drawing functions supported by scikit-image can be found in the +scikit-image reference pages.

+

First let’s make an empty, black image with a size of 800x600 pixels. +Recall that a colour image has three channels for the colours red, +green, and blue (RGB, cf. Image +Basics). Hence we need to create a 3D array of shape +(600, 800, 3) where the last dimension represents the RGB +colour channels.

+
+

PYTHON +

+
# create the black canvas
+canvas = np.zeros(shape=(600, 800, 3), dtype="uint8")
+
+

Now your task is to draw some other coloured shapes and lines on the +image, perhaps something like this:

+
Sample shapes
+
+
+
+
+ +
+
+

Drawing a circle:

+
+

PYTHON +

+
# Draw a blue circle with centre (200, 300) in (ry, cx) coordinates, and radius 100
+rr, cc = ski.draw.disk(center=(200, 300), radius=100, shape=canvas.shape[0:2])
+canvas[rr, cc] = (0, 0, 255)
+
+

Drawing a line:

+
+

PYTHON +

+
# Draw a green line from (400, 200) to (500, 700) in (ry, cx) coordinates
+rr, cc = ski.draw.line(r0=400, c0=200, r1=500, c1=700)
+canvas[rr, cc] = (0, 255, 0)
+
+
+

PYTHON +

+
# Display the image
+fig, ax = plt.subplots()
+ax.imshow(canvas)
+
+

We could expand this solution, if we wanted, to draw rectangles, +circles and lines at random positions within our black canvas. To do +this, we could use the random python module, and the +function random.randrange, which can produce random numbers +within a certain range.

+

Let’s draw 15 randomly placed circles:

+
+

PYTHON +

+
import random
+
+# create the black canvas
+canvas = np.zeros(shape=(600, 800, 3), dtype="uint8")
+
+# draw a blue circle at a random location 15 times
+for i in range(15):
+    rr, cc = ski.draw.disk(center=(
+         random.randrange(600),
+         random.randrange(800)),
+         radius=50,
+         shape=canvas.shape[0:2],
+        )
+    canvas[rr, cc] = (0, 0, 255)
+
+# display the results
+fig, ax = plt.subplots()
+ax.imshow(canvas)
+
+

We could expand this even further to also randomly choose whether to +plot a rectangle, a circle, or a square. Again, we do this with the +random module, now using the function +random.random that returns a random number between 0.0 and +1.0.

+
+

PYTHON +

+
import random
+
+# Draw 15 random shapes (rectangle, circle or line) at random positions
+for i in range(15):
+    # generate a random number between 0.0 and 1.0 and use this to decide if we
+    # want a circle, a line or a sphere
+    x = random.random()
+    if x < 0.33:
+        # draw a blue circle at a random location
+        rr, cc = ski.draw.disk(center=(
+            random.randrange(600),
+            random.randrange(800)),
+            radius=50,
+            shape=canvas.shape[0:2],
+        )
+        color = (0, 0, 255)
+    elif x < 0.66:
+        # draw a green line at a random location
+        rr, cc = ski.draw.line(
+            r0=random.randrange(600),
+            c0=random.randrange(800),
+            r1=random.randrange(600),
+            c1=random.randrange(800),
+        )
+        color = (0, 255, 0)
+    else:
+        # draw a red rectangle at a random location
+        rr, cc = ski.draw.rectangle(
+            start=(random.randrange(600), random.randrange(800)),
+            extent=(50, 50),
+            shape=canvas.shape[0:2],
+        )
+        color = (255, 0, 0)
+
+    canvas[rr, cc] = color
+
+# display the results
+fig, ax = plt.subplots()
+ax.imshow(canvas)
+
+
+
+
+
+

Image modification

+

All that remains is the task of modifying the image using our mask in +such a way that the areas with True pixels in the mask are +not shown in the image any more.

+
+
+ +
+Challenge +
+

How does a mask work? (optional, not included +in timing)

+
+

Now, consider the mask image we created above. The values of the mask +that corresponds to the portion of the image we are interested in are +all False, while the values of the mask that corresponds to +the portion of the image we want to remove are all +True.

+

How do we change the original image using the mask?

+
+
+
+
+
+ +
+
+

When indexing the image using the mask, we access only those pixels +at positions where the mask is True. So, when indexing with +the mask, one can set those values to 0, and effectively remove them +from the image.

+
+
+
+
+

Now we can write a Python program to use a mask to retain only the +portions of our maize roots image that actually contains the seedling +roots. We load the original image and create the mask in the same way as +before:

+
+

PYTHON +

+
# Load the original image
+maize_seedlings = iio.imread(uri="data/maize-seedlings.tif")
+
+# Create the basic mask
+mask = np.ones(shape=maize_seedlings.shape[0:2], dtype="bool")
+
+# Draw a filled rectangle on the mask image
+rr, cc = ski.draw.rectangle(start=(357, 44), end=(740, 720))
+mask[rr, cc] = False
+
+

Then, we use NumPy indexing to remove the portions of the image, +where the mask is True:

+
+

PYTHON +

+
# Apply the mask
+maize_seedlings[mask] = 0
+
+

Then, we display the masked image.

+
+

PYTHON +

+
fig, ax = plt.subplots()
+ax.imshow(maize_seedlings)
+
+

The resulting masked image should look like this:

+
Applied mask
+
+ +
+Challenge +
+

Masking an image of your own (optional, not +included in timing)

+
+

Now, it is your turn to practice. Using your mobile phone, tablet, +webcam, or digital camera, take an image of an object with a simple +overall geometric shape (think rectangular or circular). Copy that image +to your computer, write some code to make a mask, and apply it to select +the part of the image containing your object. For example, here is an +image of a remote control:

+
Remote control image

And, here is the end result of a program masking out everything but +the remote:

+
Remote control masked
+
+
+
+
+ +
+
+

Here is a Python program to produce the cropped remote control image +shown above. Of course, your program should be tailored to your +image.

+
+

PYTHON +

+
# Load the image
+remote = iio.imread(uri="data/remote-control.jpg")
+remote = np.array(remote)
+
+# Create the basic mask
+mask = np.ones(shape=remote.shape[0:2], dtype="bool")
+
+# Draw a filled rectangle on the mask image
+rr, cc = ski.draw.rectangle(start=(93, 1107), end=(1821, 1668))
+mask[rr, cc] = False
+
+# Apply the mask
+remote[mask] = 0
+
+# Display the result
+fig, ax = plt.subplots()
+ax.imshow(remote)
+
+
+
+
+
+
+
+ +
+Challenge +
+

Masking a 96-well plate image (30 min)

+
+

Consider this image of a 96-well plate that has been scanned on a +flatbed scanner.

+
+

PYTHON +

+
# Load the image
+wellplate = iio.imread(uri="data/wellplate-01.jpg")
+wellplate = np.array(wellplate)
+
+# Display the image
+fig, ax = plt.subplots()
+ax.imshow(wellplate)
+
+
96-well plate

Suppose that we are interested in the colours of the solutions in +each of the wells. We do not care about the colour of the rest +of the image, i.e., the plastic that makes up the well plate itself.

+

Your task is to write some code that will produce a mask that will +mask out everything except for the wells. To help with this, you should +use the text file data/centers.txt that contains the (cx, +ry) coordinates of the centre of each of the 96 wells in this image. You +may assume that each of the wells has a radius of 16 pixels.

+

Your program should produce output that looks like this:

+
Masked 96-well plate

Hint: You can load data/centers.txt using:

+
+

PYTHON +

+
# load the well coordinates as a NumPy array
+centers = np.loadtxt("data/centers.txt", delimiter=" ")
+
+
+
+
+
+
+ +
+
+
+

PYTHON +

+
# load the well coordinates as a NumPy array
+centers = np.loadtxt("data/centers.txt", delimiter=" ")
+
+# read in original image
+wellplate = iio.imread(uri="data/wellplate-01.jpg")
+wellplate = np.array(wellplate)
+
+# create the mask image
+mask = np.ones(shape=wellplate.shape[0:2], dtype="bool")
+
+# iterate through the well coordinates
+for cx, ry in centers:
+    # draw a circle on the mask at the well center
+    rr, cc = ski.draw.disk(center=(ry, cx), radius=16, shape=wellplate.shape[:2])
+    mask[rr, cc] = False
+
+# apply the mask
+wellplate[mask] = 0
+
+# display the result
+fig, ax = plt.subplots()
+ax.imshow(wellplate)
+
+
+
+
+
+
+
+ +
+Challenge +
+

Masking a 96-well plate image, take two +(optional, not included in timing)

+
+

If you spent some time looking at the contents of the +data/centers.txt file from the previous challenge, you may +have noticed that the centres of each well in the image are very +regular. Assuming that the images are scanned in such a way +that the wells are always in the same place, and that the image is +perfectly oriented (i.e., it does not slant one way or another), we +could produce our well plate mask without having to read in the +coordinates of the centres of each well. Assume that the centre of the +upper left well in the image is at location cx = 91 and ry = 108, and +that there are 70 pixels between each centre in the cx dimension and 72 +pixels between each centre in the ry dimension. Each well still has a +radius of 16 pixels. Write a Python program that produces the same +output image as in the previous challenge, but without having +to read in the centers.txt file. Hint: use nested for +loops.

+
+
+
+
+
+ +
+
+

Here is a Python program that is able to create the masked image +without having to read in the centers.txt file.

+
+

PYTHON +

+
# read in original image
+wellplate = iio.imread(uri="data/wellplate-01.jpg")
+wellplate = np.array(wellplate)
+
+# create the mask image
+mask = np.ones(shape=wellplate.shape[0:2], dtype="bool")
+
+# upper left well coordinates
+cx0 = 91
+ry0 = 108
+
+# spaces between wells
+deltaCX = 70
+deltaRY = 72
+
+cx = cx0
+ry = ry0
+
+# iterate each row and column
+for row in range(12):
+    # reset cx to leftmost well in the row
+    cx = cx0
+    for col in range(8):
+
+        # ... and drawing a circle on the mask
+        rr, cc = ski.draw.disk(center=(ry, cx), radius=16, shape=wellplate.shape[0:2])
+        mask[rr, cc] = False
+        cx += deltaCX
+    # after one complete row, move to next row
+    ry += deltaRY
+
+# apply the mask
+wellplate[mask] = 0
+
+# display the result
+fig, ax = plt.subplots()
+ax.imshow(wellplate)
+
+
+
+
+
+
+
+ +
+Key Points +
+
+
  • We can use the NumPy zeros() function to create a +blank, black image.
  • +
  • We can draw on scikit-image images with functions such as +ski.draw.rectangle(), ski.draw.disk(), +ski.draw.line(), and more.
  • +
  • The drawing functions return indices to pixels that can be set +directly.
  • +
+
+
+
+
+ + +
+
+ + + diff --git a/instructor/05-creating-histograms.html b/instructor/05-creating-histograms.html new file mode 100644 index 000000000..87ab87f4a --- /dev/null +++ b/instructor/05-creating-histograms.html @@ -0,0 +1,898 @@ + +Image Processing with Python: Creating Histograms +
+
+ + + + + +
+
+

Creating Histograms

+

Last updated on 2026-03-20 | + + Edit this page

+ + + +

Estimated time: 80 minutes

+ +
+ +
+ + + +
+

Overview

+
+
+
+
+

Questions

+
  • How can we create grayscale and colour histograms to understand the +distribution of colour values in an image?
  • +
+
+
+
+
+
+

Objectives

+
  • Explain what a histogram is.
  • +
  • Load an image in grayscale format.
  • +
  • Create and display grayscale and colour histograms for entire +images.
  • +
  • Create and display grayscale and colour histograms for certain areas +of images, via masks.
  • +
+
+
+
+
+

In this episode, we will learn how to use scikit-image functions to +create and display histograms for images.

+

First, import the packages needed for this episode

+
+

PYTHON +

+
import imageio.v3 as iio
+import ipympl
+import matplotlib.pyplot as plt
+import numpy as np
+import skimage as ski
+
+%matplotlib widget
+
+

Introduction to Histograms

+

As it pertains to images, a histogram is a graphical +representation showing how frequently various colour values occur in the +image. We saw in the Image +Basics episode that we could use a histogram to visualise the +differences in uncompressed and compressed image formats. If your +project involves detecting colour changes between images, histograms +will prove to be very useful, and histograms are also quite handy as a +preparatory step before performing thresholding.

+

Grayscale Histograms

+

We will start with grayscale images, and then move on to colour +images. We will use this image of a plant seedling as an example: Plant seedling

+

Here we load the image in grayscale instead of full colour, and +display it:

+
+

PYTHON +

+
# read the image of a plant seedling as grayscale from the outset
+plant_seedling = iio.imread(uri="data/plant-seedling.jpg", mode="L")
+
+# convert the image to float dtype with a value range from 0 to 1
+plant_seedling = ski.util.img_as_float(plant_seedling)
+
+# display the image
+fig, ax = plt.subplots()
+ax.imshow(plant_seedling, cmap="gray")
+
+
Plant seedling

Again, we use the iio.imread() function to load our +image. Then, we convert the grayscale image of integer dtype, with 0-255 +range, into a floating-point one with 0-1 range, by calling the function +ski.util.img_as_float. We can also calculate histograms for +8 bit images as we will see in the subsequent exercises.

+

We now use the function np.histogram to compute the +histogram of our image which, after all, is a NumPy array:

+
+

PYTHON +

+
# create the histogram
+histogram, bin_edges = np.histogram(plant_seedling, bins=256, range=(0, 1))
+
+

The parameter bins determines the number of “bins” to +use for the histogram. We pass in 256 because we want to +see the pixel count for each of the 256 possible values in the grayscale +image.

+

The parameter range is the range of values each of the +pixels in the image can have. Here, we pass 0 and 1, which is the value +range of our input image after conversion to floating-point.

+

The first output of the np.histogram function is a +one-dimensional NumPy array, with 256 rows and one column, representing +the number of pixels with the intensity value corresponding to the +index. I.e., the first number in the array is the number of pixels found +with intensity value 0, and the final number in the array is the number +of pixels found with intensity value 255. The second output of +np.histogram is an array with the bin edges and one column +and 257 rows (one more than the histogram itself). There are no gaps +between the bins, which means that the end of the first bin, is the +start of the second and so on. For the last bin, the array also has to +contain the stop, so it has one more element, than the histogram.

+

Next, we turn our attention to displaying the histogram, by taking +advantage of the plotting facilities of the Matplotlib library.

+
+

PYTHON +

+
# configure and draw the histogram figure
+fig, ax = plt.subplots()
+ax.set_title("Grayscale Histogram")
+ax.set_xlabel("grayscale value")
+ax.set_ylabel("pixel count")
+ax.set_xlim([0.0, 1.0])  # <- named arguments do not work here
+
+ax.plot(bin_edges[0:-1], histogram)  # <- or here
+
+

We create the plot with plt.subplots(), then label the +figure and the coordinate axes with ax.set_title(), +ax.set_xlabel(), and ax.set_ylabel() +functions. The last step in the preparation of the figure is to set the +limits on the values on the x-axis with the +ax.set_xlim([0.0, 1.0]) function call.

+
+
+ +
+Callout +
+

Variable-length argument lists

+
+

Note that we cannot used named parameters for the +ax.set_xlim() or ax.plot() functions. This is +because these functions are defined to take an arbitrary number of +unnamed arguments. The designers wrote the functions this way +because they are very versatile, and creating named parameters for all +of the possible ways to use them would be complicated.

+
+
+
+

Finally, we create the histogram plot itself with +ax.plot(bin_edges[0:-1], histogram). We use the +left bin edges as x-positions for the histogram values +by indexing the bin_edges array to ignore the last value +(the right edge of the last bin). When we run the +program on this image of a plant seedling, it produces this +histogram:

+
Plant seedling histogram
+
+ +
+Callout +
+

Histograms in Matplotlib

+
+

Matplotlib provides a dedicated function to compute and display +histograms: ax.hist(). We will not use it in this lesson in +order to understand how to calculate histograms in more detail. In +practice, it is a good idea to use this function, because it visualises +histograms more appropriately than ax.plot(). Here, you +could use it by calling +ax.hist(image.flatten(), bins=256, range=(0, 1)) instead of +np.histogram() and ax.plot() +(*.flatten() is a NumPy function that converts our +two-dimensional image into a one-dimensional array).

+
+
+
+
+
+ +
+Challenge +
+

Using a mask for a histogram (15 min)

+
+

Looking at the histogram above, you will notice that there is a large +number of very dark pixels, as indicated in the chart by the spike +around the grayscale value 0.12. That is not so surprising, since the +original image is mostly black background. What if we want to focus more +closely on the leaf of the seedling? That is where a mask enters the +picture!

+

First, hover over the plant seedling image with your mouse to +determine the (x, y) coordinates of a bounding box around the +leaf of the seedling. Then, using techniques from the Drawing and Bitwise Operations +episode, create a mask with a white rectangle covering that bounding +box.

+

After you have created the mask, apply it to the input image before +passing it to the np.histogram function.

+
+
+
+
+
+ +
+
+
+

PYTHON +

+

+# read the image as grayscale from the outset
+plant_seedling = iio.imread(uri="data/plant-seedling.jpg", mode="L")
+
+# convert the image to float dtype with a value range from 0 to 1
+plant_seedling = ski.util.img_as_float(plant_seedling)
+
+# display the image
+fig, ax = plt.subplots()
+ax.imshow(plant_seedling, cmap="gray")
+
+# create mask here, using np.zeros() and ski.draw.rectangle()
+mask = np.zeros(shape=plant_seedling.shape, dtype="bool")
+rr, cc = ski.draw.rectangle(start=(199, 410), end=(384, 485))
+mask[rr, cc] = True
+
+# display the mask
+fig, ax = plt.subplots()
+ax.imshow(mask, cmap="gray")
+
+# mask the image and create the new histogram
+histogram, bin_edges = np.histogram(plant_seedling[mask], bins=256, range=(0.0, 1.0))
+
+# configure and draw the histogram figure
+fig, ax = plt.subplots()
+
+ax.set_title("Grayscale Histogram")
+ax.set_xlabel("grayscale value")
+ax.set_ylabel("pixel count")
+ax.set_xlim([0.0, 1.0])
+ax.plot(bin_edges[0:-1], histogram)
+
+

Your histogram of the masked area should look something like +this:

+
Grayscale histogram of masked area
+
+
+
+

Colour Histograms

+

We can also create histograms for full colour images, in addition to +grayscale histograms. We have seen colour histograms before, in the Image Basics episode. A +program to create colour histograms starts in a familiar way:

+
+

PYTHON +

+
# read original image, in full color
+plant_seedling = iio.imread(uri="data/plant-seedling.jpg")
+
+# display the image
+fig, ax = plt.subplots()
+ax.imshow(plant_seedling)
+
+

We read the original image, now in full colour, and display it.

+

Next, we create the histogram, by calling the +np.histogram function three times, once for each of the +channels. We obtain the individual channels, by slicing the image along +the last axis. For example, we can obtain the red colour channel by +calling r_chan = image[:, :, 0].

+
+

PYTHON +

+
# tuple to select colors of each channel line
+colors = ("red", "green", "blue")
+
+# create the histogram plot, with three lines, one for
+# each color
+fig, ax = plt.subplots()
+ax.set_xlim([0, 256])
+for channel_id, color in enumerate(colors):
+    histogram, bin_edges = np.histogram(
+        plant_seedling[:, :, channel_id], bins=256, range=(0, 256)
+    )
+    ax.plot(bin_edges[0:-1], histogram, color=color)
+
+ax.set_title("Color Histogram")
+ax.set_xlabel("Color value")
+ax.set_ylabel("Pixel count")
+
+

We will draw the histogram line for each channel in a different +colour, and so we create a tuple of the colours to use for the three +lines with the

+

colors = ("red", "green", "blue")

+

line of code. Then, we limit the range of the x-axis with the +ax.set_xlim() function call.

+

Next, we use the for control structure to iterate +through the three channels, plotting an appropriately-coloured histogram +line for each. This may be new Python syntax for you, so we will take a +moment to discuss what is happening in the for +statement.

+

The Python built-in enumerate() function takes a list +and returns an iterator of tuples, where the first +element of the tuple is the index and the second element is the element +of the list.

+
+
+ +
+Callout +
+

Iterators, tuples, and +enumerate() +

+
+

In Python, an iterator, or an iterable object, is +something that can be iterated over with the for control +structure. A tuple is a sequence of objects, just like a list. +However, a tuple cannot be changed, and a tuple is indicated by +parentheses instead of square brackets. The enumerate() +function takes an iterable object, and returns an iterator of tuples +consisting of the 0-based index and the corresponding object.

+

For example, consider this small Python program:

+
+

PYTHON +

+
list = ("a", "b", "c", "d", "e")
+
+for x in enumerate(list):
+    print(x)
+
+

Executing this program would produce the following output:

+
+

OUTPUT +

+
(0, 'a')
+(1, 'b')
+(2, 'c')
+(3, 'd')
+(4, 'e')
+
+
+
+
+

In our colour histogram program, we are using a tuple, +(channel_id, color), as the for variable. The +first time through the loop, the channel_id variable takes +the value 0, referring to the position of the red colour +channel, and the color variable contains the string +"red". The second time through the loop the values are the +green channels index 1 and "green", and the +third time they are the blue channel index 2 and +"blue".

+

Inside the for loop, our code looks much like it did for +the grayscale example. We calculate the histogram for the current +channel with the

+

histogram, bin_edges = np.histogram(image[:, :, channel_id], bins=256, range=(0, 256))

+

function call, and then add a histogram line of the correct colour to +the plot with the

+

ax.plot(bin_edges[0:-1], histogram, color=color)

+

function call. Note the use of our loop variables, +channel_id and color.

+

Finally we label our axes and display the histogram, shown here:

+
Colour histogram
+
+ +
+Challenge +
+

Colour histogram with a mask (25 min)

+
+

We can also apply a mask to the images we apply the colour histogram +process to, in the same way we did for grayscale histograms. Consider +this image of a well plate, where various chemical sensors have been +applied to water and various concentrations of hydrochloric acid and +sodium hydroxide:

+
+

PYTHON +

+
# read the image
+wellplate = iio.imread(uri="data/wellplate-02.tif")
+
+# display the image
+fig, ax = plt.subplots()
+ax.imshow(wellplate)
+
+
Well plate image

Suppose we are interested in the colour histogram of one of the +sensors in the well plate image, specifically, the seventh well from the +left in the topmost row, which shows Erythrosin B reacting with +water.

+

Hover over the image with your mouse to find the centre of that well +and the radius (in pixels) of the well. Then create a circular mask to +select only the desired well. Then, use that mask to apply the colour +histogram operation to that well.

+

Your masked image should look like this:

+
Masked well plate

And, the program should produce a colour histogram that looks like +this:

+
Well plate histogram
+
+
+
+
+ +
+
+
+

PYTHON +

+
# create a circular mask to select the 7th well in the first row
+mask = np.zeros(shape=wellplate.shape[0:2], dtype="bool")
+circle = ski.draw.disk(center=(240, 1053), radius=49, shape=wellplate.shape[0:2])
+mask[circle] = 1
+
+# just for display:
+# make a copy of the image, call it masked_image, and
+# zero values where mask is False
+masked_img = np.array(wellplate)
+masked_img[~mask] = 0
+
+# create a new figure and display masked_img, to verify the
+# validity of your mask
+fig, ax = plt.subplots()
+ax.imshow(masked_img)
+
+# list to select colors of each channel line
+colors = ("red", "green", "blue")
+
+# create the histogram plot, with three lines, one for
+# each color
+fig, ax = plt.subplots()
+ax.set_xlim([0, 256])
+for (channel_id, color) in enumerate(colors):
+    # use your circular mask to apply the histogram
+    # operation to the 7th well of the first row
+    histogram, bin_edges = np.histogram(
+        wellplate[:, :, channel_id][mask], bins=256, range=(0, 256)
+    )
+
+    ax.plot(histogram, color=color)
+
+ax.set_xlabel("color value")
+ax.set_ylabel("pixel count")
+
+
+
+
+
+
+
+ +
+Key Points +
+
+
  • In many cases, we can load images in grayscale by passing the +mode="L" argument to the iio.imread() +function.
  • +
  • We can create histograms of images with the +np.histogram function.
  • +
  • We can display histograms using ax.plot() with the +bin_edges and histogram values returned by +np.histogram().
  • +
  • The plot can be customised using ax.set_xlabel(), +ax.set_ylabel(), ax.set_xlim(), +ax.set_ylim(), and ax.set_title().
  • +
  • We can separate the colour channels of an RGB image using slicing +operations and create histograms for each colour channel +separately.
  • +
+
+
+
+
+ + +
+
+ + + diff --git a/instructor/06-blurring.html b/instructor/06-blurring.html new file mode 100644 index 000000000..ed1ee2451 --- /dev/null +++ b/instructor/06-blurring.html @@ -0,0 +1,965 @@ + +Image Processing with Python: Blurring Images +
+
+ + + + + +
+
+

Blurring Images

+

Last updated on 2026-03-20 | + + Edit this page

+ + + +

Estimated time: 60 minutes

+ +
+ +
+ + + +
+

Overview

+
+
+
+
+

Questions

+
  • How can we apply a low-pass blurring filter to an image?
  • +
+
+
+
+
+
+

Objectives

+
  • Explain why applying a low-pass blurring filter to an image is +beneficial.
  • +
  • Apply a Gaussian blur filter to an image using scikit-image.
  • +
+
+
+
+
+

In this episode, we will learn how to use scikit-image functions to +blur images.

+

When processing an image, we are often interested in identifying +objects represented within it so that we can perform some further +analysis of these objects, e.g., by counting them, measuring their +sizes, etc. An important concept associated with the identification of +objects in an image is that of edges: the lines that represent +a transition from one group of similar pixels in the image to another +different group. One example of an edge is the pixels that represent the +boundaries of an object in an image, where the background of the image +ends and the object begins.

+

When we blur an image, we make the colour transition from one side of +an edge in the image to another smooth rather than sudden. The effect is +to average out rapid changes in pixel intensity. Blurring is a very +common operation we need to perform before other tasks such as thresholding. There are several +different blurring functions in the ski.filters module, so +we will focus on just one here, the Gaussian blur.

+
+
+ +
+Callout +
+

Filters

+
+

In the day-to-day, macroscopic world, we have physical filters which +separate out objects by size. A filter with small holes allows only +small objects through, leaving larger objects behind. This is a good +analogy for image filters. A high-pass filter will retain the smaller +details in an image, filtering out the larger ones. A low-pass filter +retains the larger features, analogous to what’s left behind by a +physical filter mesh. High- and low-pass, here, refer +to high and low spatial frequencies in the image. Details +associated with high spatial frequencies are small, a lot of these +features would fit across an image. Features associated with low spatial +frequencies are large - maybe a couple of big features per image.

+
+
+
+
+
+ +
+Callout +
+

Blurring

+
+

To blur is to make something less clear or distinct. This could be +interpreted quite broadly in the context of image analysis - anything +that reduces or distorts the detail of an image might apply. Applying a +low-pass filter, which removes detail occurring at high spatial +frequencies, is perceived as a blurring effect. A Gaussian blur is a +filter that makes use of a Gaussian kernel.

+
+
+
+
+
+ +
+Callout +
+

Kernels

+
+

A kernel can be used to implement a filter on an image. A kernel, in +this context, is a small matrix which is combined with the image using a +mathematical technique: convolution. Different sizes, shapes +and contents of kernel produce different effects. The kernel can be +thought of as a little image in itself, and will favour features of +similar size and shape in the main image. On convolution with an image, +a big, blobby kernel will retain big, blobby, low spatial frequency +features.

+
+
+
+

Gaussian blur

+

Consider this image of a cat, in particular the area of the image +outlined by the white square.

+
Cat image

Now, zoom in on the area of the cat’s eye, as shown in the left-hand +image below. When we apply a filter, we consider each pixel in the +image, one at a time. In this example, the pixel we are currently +working on is highlighted in red, as shown in the right-hand image.

+
Cat eye pixels

When we apply a filter, we consider rectangular groups of pixels +surrounding each pixel in the image, in turn. The kernel is +another group of pixels (a separate matrix / small image), of the same +dimensions as the rectangular group of pixels in the image, that moves +along with the pixel being worked on by the filter. The width and height +of the kernel must be an odd number, so that the pixel being worked on +is always in its centre. In the example shown above, the kernel is +square, with a dimension of seven pixels.

+

To apply the kernel to the current pixel, an average of the colour +values of the pixels surrounding it is calculated, weighted by the +values in the kernel. In a Gaussian blur, the pixels nearest the centre +of the kernel are given more weight than those far away from the centre. +The rate at which this weight diminishes is determined by a Gaussian +function, hence the name Gaussian blur.

+

A Gaussian function maps random variables into a normal distribution +or “Bell Curve”. Gaussian function

+ +

The shape of the function is described by a mean value μ, and a +variance value σ². The mean determines the central point of the bell +curve on the X axis, and the variance describes the spread of the +curve.

+

In fact, when using Gaussian functions in Gaussian blurring, we use a +2D Gaussian function to account for X and Y dimensions, but the same +rules apply. The mean μ is always 0, and represents the middle of the 2D +kernel. Increasing values of σ² in either dimension increases the amount +of blurring in that dimension.

+
2D Gaussian function
+

The averaging is done on a channel-by-channel basis, and the average +channel values become the new value for the pixel in the filtered image. +Larger kernels have more values factored into the average, and this +implies that a larger kernel will blur the image more than a smaller +kernel.

+

To get an idea of how this works, consider this plot of the +two-dimensional Gaussian function:

+
2D Gaussian function

Imagine that plot laid over the kernel for the Gaussian blur filter. +The height of the plot corresponds to the weight given to the underlying +pixel in the kernel. I.e., the pixels close to the centre become more +important to the filtered pixel colour than the pixels close to the +outer limits of the kernel. The shape of the Gaussian function is +controlled via its standard deviation, or sigma. A large sigma value +results in a flatter shape, while a smaller sigma value results in a +more pronounced peak. The mathematics involved in the Gaussian blur +filter are not quite that simple, but this explanation gives you the +basic idea.

+

To illustrate the blurring process, consider the blue channel colour +values from the seven-by-seven region of the cat image above:

+
Image corner pixels

The filter is going to determine the new blue channel value for the +centre pixel – the one that currently has the value 86. The filter +calculates a weighted average of all the blue channel values in the +kernel giving higher weight to the pixels near the centre of the +kernel.

+
Image multiplication

This weighted average, the sum of the multiplications, becomes the +new value for the centre pixel (3, 3). The same process would be used to +determine the green and red channel values, and then the kernel would be +moved over to apply the filter to the next pixel in the image.

+
+
+ +
+
+

Take care to avoid mixing up the term “edge” to describe the edges of +objects within an image and the outer boundaries of the images +themselves. Lack of a clear distinction here may be confusing for +learners.

+
+
+
+
+
+
+ +
+Callout +
+

Image edges

+
+

Something different needs to happen for pixels near the outer limits +of the image, since the kernel for the filter may be partially off the +image. For example, what happens when the filter is applied to the +upper-left pixel of the image? Here are the blue channel pixel values +for the upper-left pixel of the cat image, again assuming a +seven-by-seven kernel:

+
+

OUTPUT +

+
  x   x   x   x   x   x   x
+  x   x   x   x   x   x   x
+  x   x   x   x   x   x   x
+  x   x   x   4   5   9   2
+  x   x   x   5   3   6   7
+  x   x   x   6   5   7   8
+  x   x   x   5   4   5   3
+
+

The upper-left pixel is the one with value 4. Since the pixel is at +the upper-left corner, there are no pixels underneath much of the +kernel; here, this is represented by x’s. So, what does the filter do in +that situation?

+

The default mode is to fill in the nearest pixel value from +the image. For each of the missing x’s the image value closest to the x +is used. If we fill in a few of the missing pixels, you will see how +this works:

+
+

OUTPUT +

+
  x   x   x   4   x   x   x
+  x   x   x   4   x   x   x
+  x   x   x   4   x   x   x
+  4   4   4   4   5   9   2
+  x   x   x   5   3   6   7
+  x   x   x   6   5   7   8
+  x   x   x   5   4   5   3
+
+

Another strategy to fill those missing values is to reflect +the pixels that are in the image to fill in for the pixels that are +missing from the kernel.

+
+

OUTPUT +

+
  x   x   x   5   x   x   x
+  x   x   x   6   x   x   x
+  x   x   x   5   x   x   x
+  2   9   5   4   5   9   2
+  x   x   x   5   3   6   7
+  x   x   x   6   5   7   8
+  x   x   x   5   4   5   3
+
+

A similar process would be used to fill in all of the other missing +pixels from the kernel. Other border modes are available; you +can learn more about them in the scikit-image +documentation.

+
+
+
+

Let’s consider a very simple image to see blurring in action. The +animation below shows how the blur kernel (large red square) moves along +the image on the left in order to calculate the corresponding values for +the blurred image (yellow central square) on the right. In this simple +case, the original image is single-channel, but blurring would work +likewise on a multi-channel image.

+
Blur demo animation

scikit-image has built-in functions to perform blurring for us, so we +do not have to perform all of these mathematical operations ourselves. +Let’s work through an example of blurring an image with the scikit-image +Gaussian blur function.

+

First, import the packages needed for this episode:

+
+

PYTHON +

+
import imageio.v3 as iio
+import ipympl
+import matplotlib.pyplot as plt
+import skimage as ski
+
+%matplotlib widget
+
+

Then, we load the image, and display it:

+
+

PYTHON +

+
image = iio.imread(uri="data/gaussian-original.png")
+
+# display the image
+fig, ax = plt.subplots()
+ax.imshow(image)
+
+
Original image

Next, we apply the gaussian blur:

+
+

PYTHON +

+
sigma = 3.0
+
+# apply Gaussian blur, creating a new image
+blurred = ski.filters.gaussian(
+    image, sigma=(sigma, sigma), truncate=3.5, channel_axis=-1)
+
+

The first two arguments to ski.filters.gaussian() are +the image to blur, image, and a tuple defining the sigma to +use in ry- and cx-direction, (sigma, sigma). The third +parameter truncate is meant to pass the radius of the +kernel in number of sigmas. A Gaussian function is defined from +-infinity to +infinity, but our kernel (which must have a finite, +smaller size) can only approximate the real function. Therefore, we must +choose a certain distance from the centre of the function where we stop +this approximation, and set the final size of our kernel. In the above +example, we set truncate to 3.5, which means the kernel +size will be 2 * sigma * 3.5. For example, for a sigma of +1.0 the resulting kernel size would be 7, while for a sigma +of 2.0 the kernel size would be 14. The default value for +truncate in scikit-image is 4.0.

+

The last argument we passed to ski.filters.gaussian() is +used to specify the dimension which contains the (colour) channels. +Here, it is the last dimension; recall that, in Python, the +-1 index refers to the last position. In this case, the +last dimension is the third dimension (index 2), since our +image has three dimensions:

+
+

PYTHON +

+
print(image.ndim)
+
+
+

OUTPUT +

+
3
+
+

Finally, we display the blurred image:

+
+

PYTHON +

+
# display blurred image
+fig, ax = plt.subplots()
+ax.imshow(blurred)
+
+
Blurred image

Visualising Blurring

+

Somebody said once “an image is worth a thousand words”. What is +actually happening to the image pixels when we apply blurring may be +difficult to grasp. Let’s now visualise the effects of blurring from a +different perspective.

+

Let’s use the petri-dish image from previous episodes:

+
Bacteria colony
Graysacle version of the Petri dish image
+

What we want to see here is the pixel intensities from a lateral +perspective: we want to see the profile of intensities. For instance, +let’s look for the intensities of the pixels along the horizontal line +at Y=150:

+
+

PYTHON +

+
# read colonies color image and convert to grayscale
+image = iio.imread('data/colonies-01.tif')
+image_gray = ski.color.rgb2gray(image)
+
+# define the pixels for which we want to view the intensity (profile)
+xmin, xmax = (0, image_gray.shape[1])
+Y = ymin = ymax = 150
+
+# view the image indicating the profile pixels position
+fig, ax = plt.subplots()
+ax.imshow(image_gray, cmap='gray')
+ax.plot([xmin, xmax], [ymin, ymax], color='red')
+
+
Bacteria colony image with selected pixels marker
Grayscale Petri dish image marking selected +pixels for profiling
+

The intensity of those pixels we can see with a simple line plot:

+
+

PYTHON +

+
# select the vector of pixels along "Y"
+image_gray_pixels_slice = image_gray[Y, :]
+
+# guarantee the intensity values are in the [0:255] range (unsigned integers)
+image_gray_pixels_slice = ski.img_as_ubyte(image_gray_pixels_slice)
+
+fig, ax = plt.subplots()
+ax.plot(image_gray_pixels_slice, color='red')
+ax.set_ylim(255, 0)
+ax.set_ylabel('L')
+ax.set_xlabel('X')
+
+
Pixel intensities profile in original image
Intensities profile line plot of pixels along +Y=150 in original image
+

And now, how does the same set of pixels look in the corresponding +blurred image:

+
+

PYTHON +

+
# first, create a blurred version of (grayscale) image
+image_blur = ski.filters.gaussian(image_gray, sigma=3)
+
+# like before, plot the pixels profile along "Y"
+image_blur_pixels_slice = image_blur[Y, :]
+image_blur_pixels_slice = ski.img_as_ubyte(image_blur_pixels_slice)
+
+fig, ax = plt.subplots()
+ax.plot(image_blur_pixels_slice, 'red')
+ax.set_ylim(255, 0)
+ax.set_ylabel('L')
+ax.set_xlabel('X')
+
+
Pixel intensities profile in blurred image
Intensities profile of pixels along Y=150 in +blurred image
+

And that is why blurring is also called smoothing. +This is how low-pass filters affect neighbouring pixels.

+

Now that we have seen the effects of blurring an image from two +different perspectives, front and lateral, let’s take yet another look +using a 3D visualisation.

+
+
+ +
+Callout +
+

3D Plots with matplotlib

+
+

The code to generate these 3D plots is outside the scope of this +lesson but can be viewed by following the links in the captions.

+
+
+
+
3D surface plot showing pixel intensities across the whole example Petri dish image before blurring
A 3D plot of pixel intensities across the whole +Petri dish image before blurring. Explore +how this plot was created with matplotlib. Image credit: Carlos H Brandt.
+
3D surface plot illustrating the smoothing effect on pixel intensities across the whole example Petri dish image after blurring
A 3D plot of pixel intensities after Gaussian +blurring of the Petri dish image. Note the ‘smoothing’ effect on the +pixel intensities of the colonies in the image, and the ‘flattening’ of +the background noise at relatively low pixel intensities throughout the +image. Explore +how this plot was created with matplotlib. Image credit: Carlos H Brandt.
+
+
+ +
+Challenge +
+

Experimenting with sigma values (10 min)

+
+

The size and shape of the kernel used to blur an image can have a +significant effect on the result of the blurring and any downstream +analysis carried out on the blurred image. The next two exercises ask +you to experiment with the sigma values of the kernel, which is a good +way to develop your understanding of how the choice of kernel can +influence the result of blurring.

+

First, try running the code above with a range of smaller and larger +sigma values. Generally speaking, what effect does the sigma value have +on the blurred image?

+
+
+
+
+
+ +
+
+

Generally speaking, the larger the sigma value, the more blurry the +result. A larger sigma will tend to get rid of more noise in the image, +which will help for other operations we will cover soon, such as +thresholding. However, a larger sigma also tends to eliminate some of +the detail from the image. So, we must strike a balance with the sigma +value used for blur filters.

+
+
+
+
+
+
+ +
+Challenge +
+

Experimenting with kernel shape (10 min - +optional, not included in timing)

+
+

Now, what is the effect of applying an asymmetric kernel to blurring +an image? Try running the code above with different sigmas in the ry and +cx direction. For example, a sigma of 1.0 in the ry direction, and 6.0 +in the cx direction.

+
+
+
+
+
+ +
+
+
+

PYTHON +

+
# apply Gaussian blur, with a sigma of 1.0 in the ry direction, and 6.0 in the cx direction
+blurred = ski.filters.gaussian(
+    image, sigma=(1.0, 6.0), truncate=3.5, channel_axis=-1
+)
+
+# display blurred image
+fig, ax = plt.subplots()
+ax.imshow(blurred)
+
+
Rectangular kernel blurred image

These unequal sigma values produce a kernel that is rectangular +instead of square. The result is an image that is much more blurred in +the X direction than in the Y direction. For most use cases, a uniform +blurring effect is desirable and this kind of asymmetric blurring should +be avoided. However, it can be helpful in specific circumstances, e.g., +when noise is present in your image in a particular pattern or +orientation, such as vertical lines, or when you want to remove +uniform noise without blurring edges present in the image in a +particular orientation.

+
+
+
+
+

Other methods of blurring

+

The Gaussian blur is a way to apply a low-pass filter in +scikit-image. It is often used to remove Gaussian (i.e., random) noise +in an image. For other kinds of noise, e.g., “salt and pepper”, a median +filter is typically used. See the +skimage.filters documentation for a list of available +filters.

+
+
+ +
+Key Points +
+
+
  • Applying a low-pass blurring filter smooths edges and removes noise +from an image.
  • +
  • Blurring is often used as a first step before we perform +thresholding or edge detection.
  • +
  • The Gaussian blur can be applied to an image with the +ski.filters.gaussian() function.
  • +
  • Larger sigma values may remove more noise, but they will also remove +detail from an image.
  • +
+
+
+
+
+ + +
+
+ + + diff --git a/instructor/07-thresholding.html b/instructor/07-thresholding.html new file mode 100644 index 000000000..8e7d857c1 --- /dev/null +++ b/instructor/07-thresholding.html @@ -0,0 +1,1265 @@ + +Image Processing with Python: Thresholding +
+
+ + + + + +
+
+

Thresholding

+

Last updated on 2026-04-28 | + + Edit this page

+ + + +

Estimated time: 110 minutes

+ +
+ +
+ + + +
+

Overview

+
+
+
+
+

Questions

+
  • How can we use thresholding to produce a binary image?
  • +
+
+
+
+
+
+

Objectives

+
  • Explain what thresholding is and how it can be used.
  • +
  • Use histograms to determine appropriate threshold values to use for +the thresholding process.
  • +
  • Apply simple, fixed-level binary thresholding to an image.
  • +
  • Explain the difference between using the operator > +or the operator < to threshold an image represented by a +NumPy array.
  • +
  • Describe the shape of a binary image produced by thresholding via +> or <.
  • +
  • Explain when Otsu’s method for automatic thresholding is +appropriate.
  • +
  • Apply automatic thresholding to an image using Otsu’s method.
  • +
  • Use the np.count_nonzero() function to count the number +of non-zero pixels in an image.
  • +
+
+
+
+
+

In this episode, we will learn how to use scikit-image functions to +apply thresholding to an image. Thresholding is a type of image +segmentation, where an image is split into different regions, or +segments. These segments can then be analyzed separately.

+

In thresholding, we convert an image from colour or grayscale into a +binary image, i.e., one that is simply black and white. Most +frequently, we use thresholding as a way to select areas of interest of +an image, while ignoring the parts we are not concerned with. We have +already done some simple thresholding, in the “Manipulating pixels” +section of the Working with +scikit-image episode. In that case, we used a simple NumPy +array manipulation to separate the pixels belonging to the root system +of a plant from the black background. In this episode, we will learn how +to use scikit-image functions to perform thresholding. Then, we will use +the masks returned by these functions to select the parts of an image we +are interested in.

+

First, import the packages needed for this episode

+
+

PYTHON +

+
import glob
+
+import imageio.v3 as iio
+import ipympl
+import matplotlib.pyplot as plt
+import numpy as np
+import skimage as ski
+
+%matplotlib widget
+
+

Simple thresholding

+

Consider the image data/shapes-01.jpg with a series of +crudely cut shapes set against a white background.

+
+

PYTHON +

+
# load the image
+shapes01 = iio.imread(uri="data/shapes-01.jpg")
+
+fig, ax = plt.subplots()
+ax.imshow(shapes01)
+
+
Image with geometric shapes on white background

Now suppose we want to select only the shapes from the image. In +other words, we want to leave the pixels belonging to the shapes “on,” +while turning the rest of the pixels “off,” by setting their colour +channel values to zeros. The scikit-image library has several different +methods of thresholding. We will start with the simplest version, which +involves an important step of human input. Specifically, in this simple, +fixed-level thresholding, we have to provide a threshold value +t.

+

The process works like this. First, we will load the original image, +convert it to grayscale, and de-noise it as in the Blurring Images episode.

+
+

PYTHON +

+
# convert the image to grayscale
+gray_shapes = ski.color.rgb2gray(shapes01)
+
+# blur the image to denoise
+blurred_shapes = ski.filters.gaussian(gray_shapes, sigma=1.0)
+
+fig, ax = plt.subplots()
+ax.imshow(blurred_shapes, cmap="gray")
+
+
Grayscale image of the geometric shapes

Next, we would like to apply the threshold t such that +pixels with grayscale values on one side of t will be +turned “on”, while pixels with grayscale values on the other side will +be turned “off”. How might we do that? Remember that grayscale images +contain pixel values in the range from 0 to 1, so we are looking for a +threshold t in the closed range [0.0, 1.0]. We see in the +image that the geometric shapes are “darker” than the white background +but there is also some light gray noise on the background. One way to +determine a “good” value for t is to look at the grayscale +histogram of the image and try to identify what grayscale ranges +correspond to the shapes in the image or the background.

+

The histogram for the shapes image shown above can be produced as in +the Creating Histograms +episode.

+
+

PYTHON +

+
# create a histogram of the blurred grayscale image
+histogram, bin_edges = np.histogram(blurred_shapes, bins=256, range=(0.0, 1.0))
+
+fig, ax = plt.subplots()
+ax.plot(bin_edges[0:-1], histogram)
+ax.set_title("Grayscale Histogram")
+ax.set_xlabel("grayscale value")
+ax.set_ylabel("pixels")
+ax.set_xlim(0, 1.0)
+
+
Grayscale histogram of the geometric shapes image

Since the image has a white background, most of the pixels in the +image are white. This corresponds nicely to what we see in the +histogram: there is a peak near the value of 1.0. If we want to select +the shapes and not the background, we want to turn off the white +background pixels, while leaving the pixels for the shapes turned on. +So, we should choose a value of t somewhere before the +large peak and turn pixels above that value “off”. Let us choose +t=0.8.

+

To apply the threshold t, we can use the NumPy +comparison operators to create a mask. Here, we want to turn “on” all +pixels which have values smaller than the threshold, so we use the less +operator < to compare the blurred_image to +the threshold t. The operator returns a mask, that we +capture in the variable binary_mask. It has only one +channel, and each of its values is either 0 or 1. The binary mask +created by the thresholding operation can be shown with +ax.imshow, where the False entries are shown +as black pixels (0-valued) and the True entries are shown +as white pixels (1-valued).

+
+

PYTHON +

+
# create a mask based on the threshold
+t = 0.8
+binary_mask = blurred_shapes < t
+
+fig, ax = plt.subplots()
+ax.imshow(binary_mask, cmap="gray")
+
+
Binary mask of the geometric shapes created by thresholding

You can see that the areas where the shapes were in the original area +are now white, while the rest of the mask image is black.

+
+
+ +
+Callout +
+

What makes a good threshold?

+
+

As is often the case, the answer to this question is “it depends”. In +the example above, we could have just switched off all the white +background pixels by choosing t=1.0, but this would leave +us with some background noise in the mask image. On the other hand, if +we choose too low a value for the threshold, we could lose some of the +shapes that are too bright. You can experiment with the threshold by +re-running the above code lines with different values for +t. In practice, it is a matter of domain knowledge and +experience to interpret the peaks in the histogram so to determine an +appropriate threshold. The process often involves trial and error, which +is a drawback of the simple thresholding method. Below we will introduce +automatic thresholding, which uses a quantitative, mathematical +definition for a good threshold that allows us to determine the value of +t automatically. It is worth noting that the principle for +simple and automatic thresholding can also be used for images with pixel +ranges other than [0.0, 1.0]. For example, we could perform thresholding +on pixel intensity values in the range [0, 255] as we have already seen +in the Working with +scikit-image episode.

+
+
+
+

We can now apply the binary_mask to the original +coloured image as we have learned in the +Drawing and Bitwise Operations episode. What we are left +with is only the coloured shapes from the original.

+
+

PYTHON +

+
# use the binary_mask to select the "interesting" part of the image
+selection = shapes01.copy()
+selection[~binary_mask] = 0
+
+fig, ax = plt.subplots()
+ax.imshow(selection)
+
+
Selected shapes after applying binary mask
+
+ +
+Challenge +
+

More practice with simple thresholding (15 +min)

+
+

Now, it is your turn to practice. Suppose we want to use simple +thresholding to select only the coloured shapes (in this particular case +we consider grayish to be a colour, too) from the image +data/shapes-02.jpg:

+
Another image with geometric shapes on white background

First, plot the grayscale histogram as in the Creating Histogram episode and +examine the distribution of grayscale values in the image. What do you +think would be a good value for the threshold t?

+
+
+
+
+
+ +
+
+

The histogram for the data/shapes-02.jpg image can be +shown with

+
+

PYTHON +

+
shapes = iio.imread(uri="data/shapes-02.jpg")
+gray_shapes = ski.color.rgb2gray(shapes)
+histogram, bin_edges = np.histogram(gray_shapes, bins=256, range=(0.0, 1.0))
+
+fig, ax = plt.subplots()
+ax.plot(bin_edges[0:-1], histogram)
+ax.set_title("Graylevel histogram")
+ax.set_xlabel("gray value")
+ax.set_ylabel("pixel count")
+ax.set_xlim(0, 1.0)
+
+
Grayscale histogram of the second geometric shapes image

We can see a large spike around 0.3, and a smaller spike around 0.7. +The spike near 0.3 represents the darker background, so it seems like a +value close to t=0.5 would be a good choice.

+
+
+
+
+
+
+ +
+Challenge +
+

More practice with simple thresholding (15 +min) (continued) +

+
+

Next, create a mask to turn the pixels above the threshold +t on and pixels below the threshold t off. +Note that unlike the image with a white background we used above, here +the peak for the background colour is at a lower gray level than the +shapes. Therefore, change the comparison operator less < +to greater > to create the appropriate mask. Then apply +the mask to the image and view the thresholded image. If everything +works as it should, your output should show only the coloured shapes on +a black background.

+
+
+
+
+
+ +
+
+

Here are the commands to create and view the binary mask

+
+

PYTHON +

+
t = 0.5
+binary_mask = gray_shapes > t
+
+fig, ax = plt.subplots()
+ax.imshow(binary_mask, cmap="gray")
+
+
Binary mask created by thresholding the second geometric shapes image

And here are the commands to apply the mask and view the thresholded +image

+
+

PYTHON +

+
shapes02 = iio.imread(uri="data/shapes-02.jpg")
+selection = shapes02.copy()
+selection[~binary_mask] = 0
+
+fig, ax = plt.subplots()
+ax.imshow(selection)
+
+
Selected shapes after applying binary mask to the second geometric shapes image
+
+
+
+

Automatic thresholding

+

The downside of the simple thresholding technique is that we have to +make an educated guess about the threshold t by inspecting +the histogram. There are also automatic thresholding methods +that can determine the threshold automatically for us. One such method +is Otsu’s +method. It is particularly useful for situations where the +grayscale histogram of an image has two peaks that correspond to +background and objects of interest.

+
+
+ +
+Callout +
+

Denoising an image before thresholding

+
+

In practice, it is often necessary to denoise the image before +thresholding, which can be done with one of the methods from the Blurring Images episode.

+
+
+
+

Consider the image data/maize-root-cluster.jpg of a +maize root system which we have seen before in the Working with scikit-image +episode.

+
+

PYTHON +

+
maize_roots = iio.imread(uri="data/maize-root-cluster.jpg")
+
+fig, ax = plt.subplots()
+ax.imshow(maize_roots)
+
+
Image of a maize root

We use Gaussian blur with a sigma of 1.0 to denoise the root image. +Let us look at the grayscale histogram of the denoised image.

+
+

PYTHON +

+
# convert the image to grayscale
+gray_image = ski.color.rgb2gray(maize_roots)
+
+# blur the image to denoise
+blurred_image = ski.filters.gaussian(gray_image, sigma=1.0)
+
+# show the histogram of the blurred image
+histogram, bin_edges = np.histogram(blurred_image, bins=256, range=(0.0, 1.0))
+fig, ax = plt.subplots()
+ax.plot(bin_edges[0:-1], histogram)
+ax.set_title("Graylevel histogram")
+ax.set_xlabel("gray value")
+ax.set_ylabel("pixel count")
+ax.set_xlim(0, 1.0)
+
+
Grayscale histogram of the maize root image

The histogram has a significant peak around 0.2 and then a broader +“hill” around 0.6 followed by a smaller peak near 1.0. Looking at the +grayscale image, we can identify the peak at 0.2 with the background and +the broader peak with the foreground. Thus, this image is a good +candidate for thresholding with Otsu’s method. The mathematical details +of how this works are complicated (see the +scikit-image documentation if you are interested), but the outcome +is that Otsu’s method finds a threshold value between the two peaks of a +grayscale histogram which might correspond well to the foreground and +background depending on the data and application.

+
+
+ +
+
+

The histogram of the maize root image may prompt questions from +learners about the interpretation of the peaks and the broader region +around 0.6. The focus here is on the separation of background and +foreground pixel values. We note that Otsu’s method does not work well +for the image with the shapes used earlier in this episode, as the +foreground pixel values are more distributed. These examples could be +augmented with a discussion of unimodal, bimodal, and multimodal +histograms. While these points can lead to fruitful considerations, the +text in this episode attempts to reduce cognitive load and deliberately +simplifies the discussion.

+
+
+
+
+

The ski.filters.threshold_otsu() function can be used to +determine the threshold automatically via Otsu’s method. Then NumPy +comparison operators can be used to apply it as before. Here are the +Python commands to determine the threshold t with Otsu’s +method.

+
+

PYTHON +

+
# perform automatic thresholding
+t = ski.filters.threshold_otsu(blurred_image)
+print("Found automatic threshold t = {}.".format(t))
+
+
+

OUTPUT +

+
Found automatic threshold t = 0.4116003928683858.
+
+

For this root image and a Gaussian blur with the chosen sigma of 1.0, +the computed threshold value is 0.42. No we can create a binary mask +with the comparison operator >. As we have seen before, +pixels above the threshold value will be turned on, those below the +threshold will be turned off.

+
+

PYTHON +

+
# create a binary mask with the threshold found by Otsu's method
+binary_mask = blurred_image > t
+
+fig, ax = plt.subplots()
+ax.imshow(binary_mask, cmap="gray")
+
+
Binary mask of the maize root system

Finally, we use the mask to select the foreground:

+
+

PYTHON +

+
# apply the binary mask to select the foreground
+selection = maize_roots.copy()
+selection[~binary_mask] = 0
+
+fig, ax = plt.subplots()
+ax.imshow(selection)
+
+
Masked selection of the maize root system

Application: measuring root mass

+

Let us now turn to an application where we can apply thresholding and +other techniques we have learned to this point. Consider these four +maize root system images, which you can find in the files +data/trial-016.jpg, data/trial-020.jpg, +data/trial-216.jpg, and +data/trial-293.jpg.

+
Four images of maize roots

Suppose we are interested in the amount of plant material in each +image, and in particular how that amount changes from image to image. +Perhaps the images represent the growth of the plant over time, or +perhaps the images show four different maize varieties at the same phase +of their growth. The question we would like to answer is, “how much root +mass is in each image?”

+

We will first construct a Python program to measure this value for a +single image. Our strategy will be this:

+
  1. Read the image, converting it to grayscale as it is read. For this +application we do not need the colour image.
  2. +
  3. Blur the image.
  4. +
  5. Use Otsu’s method of thresholding to create a binary image, where +the pixels that were part of the maize plant are white, and everything +else is black.
  6. +
  7. Save the binary image so it can be examined later.
  8. +
  9. Count the white pixels in the binary image, and divide by the number +of pixels in the image. This ratio will be a measure of the root mass of +the plant in the image.
  10. +
  11. Output the name of the image processed and the root mass ratio.
  12. +

Our intent is to perform these steps and produce the numeric result - +a measure of the root mass in the image - without human intervention. +Implementing the steps within a Python function will enable us to call +this function for different images.

+

Here is a Python function that implements this root-mass-measuring +strategy. Since the function is intended to produce numeric output +without human interaction, it does not display any of the images. Almost +all of the commands should be familiar, and in fact, it may seem simpler +than the code we have worked on thus far, because we are not displaying +any of the images.

+
+

PYTHON +

+
def measure_root_mass(filename, sigma=1.0):
+
+    # read the original image, converting to grayscale on the fly
+    image = iio.imread(uri=filename, mode="L")
+
+    # blur before thresholding
+    blurred_image = ski.filters.gaussian(image, sigma=sigma)
+
+    # perform automatic thresholding to produce a binary image
+    t = ski.filters.threshold_otsu(blurred_image)
+    binary_mask = blurred_image > t
+
+    # determine root mass ratio
+    root_pixels = np.count_nonzero(binary_mask)
+    density = root_pixels / binary_mask.size
+
+    return density
+
+

The function begins with reading the original image from the file +filename. We use iio.imread() with the +optional argument mode="L" to automatically convert it to +grayscale. Next, the grayscale image is blurred with a Gaussian filter +with the value of sigma that is passed to the function. +Then we determine the threshold t with Otsu’s method and +create a binary mask just as we did in the previous section. Up to this +point, everything should be familiar.

+

The final part of the function determines the root mass ratio in the +image. Recall that in the binary_mask, every pixel has +either a value of zero (black/background) or one (white/foreground). We +want to count the number of white pixels, which can be accomplished with +a call to the NumPy function np.count_nonzero. Finally, the +density ratio is calculated by dividing the number of white pixels by +the total number of pixels binary_mask.size in the image. +The function returns then root density of the image.

+

We can call this function with any filename and provide a sigma value +for the blurring. If no sigma value is provided, the default value 1.0 +will be used. For example, for the file data/trial-016.jpg +and a sigma value of 1.5, we would call the function like this:

+
+

PYTHON +

+
measure_root_mass(filename="data/trial-016.jpg", sigma=1.5)
+
+
+

OUTPUT +

+
0.04907247340425532
+
+

Now we can use the function to process the series of four images +shown above. In a real-world scientific situation, there might be +dozens, hundreds, or even thousands of images to process. To save us the +tedium of calling the function for each image by hand, we can write a +loop that processes all files automatically. The following code block +assumes that the files are located in the same directory and the +filenames all start with the trial- prefix and end with +the .jpg suffix.

+
+

PYTHON +

+
all_files = sorted(glob.glob("data/trial-*.jpg"))
+for filename in all_files:
+    density = measure_root_mass(filename=filename, sigma=1.5)
+    # output in format suitable for .csv
+    print(filename, density, sep=",")
+
+
+

OUTPUT +

+
data/trial-016.jpg,0.04907247340425532
+data/trial-020.jpg,0.06381366356382978
+data/trial-216.jpg,0.14205152925531914
+data/trial-293.jpg,0.13665791223404256
+
+
+
+ +
+Callout +
+
+

Compare your results with the values above. Do they match exactly? +You may find that certain decimal values differ slightly, even when +using identical input parameters.

+

This variation often stems from the specific versions of your +installed packages (such as numpy or +scikit-image). As these libraries evolve, updates can +introduce subtle changes in numerical handling, underlying algorithms, +or rounding logic. This highlights why reproducible environments, as +well as reproducible code, are essential for consistent scientific +computing.

+
+
+
+
+
+ +
+Challenge +
+

Ignoring more of the images – brainstorming +(10 min)

+
+

Let us take a closer look at the binary masks produced by the +measure_root_mass function.

+
Binary masks of the four maize root images

You may have noticed in the section on automatic thresholding that +the thresholded image does include regions of the image aside of the +plant root: the numbered labels and the white circles in each image are +preserved during the thresholding, because their grayscale values are +above the threshold. Therefore, our calculated root mass ratios include +the white pixels of the label and white circle that are not part of the +plant root. Those extra pixels affect how accurate the root mass +calculation is!

+

How might we remove the labels and circles before calculating the +ratio, so that our results are more accurate? Think about some options +given what we have learned so far.

+
+
+
+
+
+ +
+
+

One approach we might take is to try to completely mask out a region +from each image, particularly, the area containing the white circle and +the numbered label. If we had coordinates for a rectangular area on the +image that contained the circle and the label, we could mask the area +out by using techniques we learned in the +Drawing and Bitwise Operations episode.

+

However, a closer inspection of the binary images raises some issues +with that approach. Since the roots are not always constrained to a +certain area in the image, and since the circles and labels are in +different locations each time, we would have difficulties coming up with +a single rectangle that would work for every image. We could +create a different masking rectangle for each image, but that is not a +practicable approach if we have hundreds or thousands of images to +process.

+

Another approach we could take is to apply two thresholding steps to +the image. Look at the graylevel histogram of the file +data/trial-016.jpg shown above again: Notice the peak near +1.0? Recall that a grayscale value of 1.0 corresponds to white pixels: +the peak corresponds to the white label and circle. So, we could use +simple binary thresholding to mask the white circle and label from the +image, and then we could use Otsu’s method to select the pixels in the +plant portion of the image.

+

Note that most of this extra work in processing the image could have +been avoided during the experimental design stage, with some careful +consideration of how the resulting images would be used. For example, +all of the following measures could have made the images easier to +process, by helping us predict and/or detect where the label is in the +image and subsequently mask it from further processing:

+
  • Using labels with a consistent size and shape
  • +
  • Placing all the labels in the same position, relative to the +sample
  • +
  • Using a non-white label, with non-black writing
  • +
+
+
+
+
+
+ +
+Challenge +
+

Ignoring more of the images – implementation +(30 min - optional, not included in timing)

+
+

Implement an enhanced version of the function +measure_root_mass that applies simple binary thresholding +to remove the white circle and label from the image before applying +Otsu’s method.

+
+
+
+
+
+ +
+
+

We can apply a simple binary thresholding with a threshold +t=0.95 to remove the label and circle from the image. We +can then use the binary mask to calculate the Otsu threshold without the +pixels from the label and circle.

+
+

PYTHON +

+
def enhanced_root_mass(filename, sigma):
+
+    # read the original image, converting to grayscale on the fly
+    image = iio.imread(uri=filename, mode="L")
+
+    # blur before thresholding
+    blurred_image = ski.filters.gaussian(image, sigma=sigma)
+
+    # perform binary thresholding to mask the white label and circle
+    binary_mask = blurred_image < 0.95
+
+    # perform automatic thresholding using only the pixels with value True in the binary mask
+    t = ski.filters.threshold_otsu(blurred_image[binary_mask])
+
+    # update binary mask to identify pixels which are both less than 0.95 and greater than t
+    binary_mask = (blurred_image < 0.95) & (blurred_image > t)
+
+    # determine root mass ratio
+    root_pixels = np.count_nonzero(binary_mask)
+    density = root_pixels / binary_mask.size
+
+    return density
+
+
+all_files = sorted(glob.glob("data/trial-*.jpg"))
+for filename in all_files:
+    density = enhanced_root_mass(filename=filename, sigma=1.5)
+    # output in format suitable for .csv
+    print(filename, density, sep=",")
+
+

The output of the improved program does illustrate that the white +circles and labels were skewing our root mass ratios:

+
+

OUTPUT +

+
data/trial-016.jpg,0.046261136968085106
+data/trial-020.jpg,0.05887167553191489
+data/trial-216.jpg,0.13712067819148935
+data/trial-293.jpg,0.1319044215425532
+
+
+
+ +
+
+

The & operator above means that we have defined a +logical AND statement. This combines the two tests of pixel intensities +in the blurred image such that both must be true for a pixel’s position +to be set to True in the resulting mask.

+ + + + + + + + + + + + +
Result of t < blurred_image +Result of blurred_image < 0.95 +Resulting value in binary_mask +
FalseTrueFalse
TrueFalseFalse
TrueTrueTrue

Knowing how to construct this kind of logical operation can be very +helpful in image processing. The University of Minnesota Library’s guide to Boolean +operators is a good place to start if you want to learn more.

+
+
+
+
+

Here are the binary images produced by the additional thresholding. +Note that we have not completely removed the offending white pixels. +Outlines still remain. However, we have reduced the number of extraneous +pixels, which should make the output more accurate.

+
Improved binary masks of the four maize root images
+
+
+
+
+
+ +
+Challenge +
+

Thresholding a bacteria colony image (15 +min)

+
+

In the images directory data/, you will find an image +named colonies-01.tif.

+
Image of bacteria colonies in a petri dish

This is one of the images you will be working with in the +morphometric challenge at the end of the workshop.

+
  1. Plot and inspect the grayscale histogram of the image to determine a +good threshold value for the image.
  2. +
  3. Create a binary mask that leaves the pixels in the bacteria colonies +“on” while turning the rest of the pixels in the image “off”.
  4. +
+
+
+
+
+ +
+
+

Here is the code to create the grayscale histogram:

+
+

PYTHON +

+
bacteria = iio.imread(uri="data/colonies-01.tif")
+gray_image = ski.color.rgb2gray(bacteria)
+blurred_image = ski.filters.gaussian(gray_image, sigma=1.0)
+histogram, bin_edges = np.histogram(blurred_image, bins=256, range=(0.0, 1.0))
+fig, ax = plt.subplots()
+ax.plot(bin_edges[0:-1], histogram)
+ax.set_title("Graylevel histogram")
+ax.set_xlabel("gray value")
+ax.set_ylabel("pixel count")
+ax.set_xlim(0, 1.0)
+
+
Grayscale histogram of the bacteria colonies image

The peak near one corresponds to the white image background, and the +broader peak around 0.5 corresponds to the yellow/brown culture medium +in the dish. The small peak near zero is what we are after: the dark +bacteria colonies. A reasonable choice thus might be to leave pixels +below t=0.2 on.

+

Here is the code to create and show the binarized image using the +< operator with a threshold t=0.2:

+
+

PYTHON +

+
t = 0.2
+binary_mask = blurred_image < t
+
+fig, ax = plt.subplots()
+ax.imshow(binary_mask, cmap="gray")
+
+
Binary mask of the bacteria colonies image

When you experiment with the threshold a bit, you can see that in +particular the size of the bacteria colony near the edge of the dish in +the top right is affected by the choice of the threshold.

+
+
+
+
+
+
+ +
+Key Points +
+
+
  • Thresholding produces a binary image, where all pixels with +intensities above (or below) a threshold value are turned on, while all +other pixels are turned off.
  • +
  • The binary images produced by thresholding are held in +two-dimensional NumPy arrays, since they have only one colour value +channel. They are boolean, hence they contain the values 0 (off) and 1 +(on).
  • +
  • Thresholding can be used to create masks that select only the +interesting parts of an image, or as the first step before edge +detection or finding contours.
  • +
+
+
+
+
+ + +
+
+ + + diff --git a/instructor/08-connected-components.html b/instructor/08-connected-components.html new file mode 100644 index 000000000..8c028b1f7 --- /dev/null +++ b/instructor/08-connected-components.html @@ -0,0 +1,1320 @@ + +Image Processing with Python: Connected Component Analysis +
+
+ + + + + +
+
+

Connected Component Analysis

+

Last updated on 2026-03-20 | + + Edit this page

+ + + +

Estimated time: 125 minutes

+ +
+ +
+ + + +
+

Overview

+
+
+
+
+

Questions

+
  • How to extract separate objects from an image and describe these +objects quantitatively.
  • +
+
+
+
+
+
+

Objectives

+
  • Understand the term object in the context of images.
  • +
  • Learn about pixel connectivity.
  • +
  • Learn how Connected Component Analysis (CCA) works.
  • +
  • Use CCA to produce an image that highlights every object in a +different colour.
  • +
  • Characterise each object with numbers that describe its +appearance.
  • +
+
+
+
+
+

Objects

+

In the Thresholding +episode we have covered dividing an image into foreground and +background pixels. In the shapes example image, we considered the +coloured shapes as foreground objects on a white +background.

+
Original shapes image

In thresholding we went from the original image to this version:

+
Mask created by thresholding

Here, we created a mask that only highlights the parts of the image +that we find interesting, the objects. All objects have pixel +value of True while the background pixels are +False.

+

By looking at the mask image, one can count the objects that are +present in the image (7). But how did we actually do that, how did we +decide which lump of pixels constitutes a single object?

+ +

Pixel Neighborhoods

+

In order to decide which pixels belong to the same object, one can +exploit their neighborhood: pixels that are directly next to each other +and belong to the foreground class can be considered to belong to the +same object.

+

Let’s discuss the concept of pixel neighborhoods in more detail. +Consider the following mask “image” with 8 rows, and 8 columns. For the +purpose of illustration, the digit 0 is used to represent +background pixels, and the letter X is used to represent +object pixels foreground).

+
+

OUTPUT +

+
0 0 0 0 0 0 0 0
+0 X X 0 0 0 0 0
+0 X X 0 0 0 0 0
+0 0 0 X X X 0 0
+0 0 0 X X X X 0
+0 0 0 0 0 0 0 0
+
+

The pixels are organised in a rectangular grid. In order to +understand pixel neighborhoods we will introduce the concept of “jumps” +between pixels. The jumps follow two rules: First rule is that one jump +is only allowed along the column, or the row. Diagonal jumps are not +allowed. So, from a centre pixel, denoted with o, only the +pixels indicated with a 1 are reachable:

+
+

OUTPUT +

+
- 1 -
+1 o 1
+- 1 -
+
+

The pixels on the diagonal (from o) are not reachable +with a single jump, which is denoted by the -. The pixels +reachable with a single jump form the 1-jump +neighborhood.

+

The second rule states that in a sequence of jumps, one may only jump +in row and column direction once -> they have to be +orthogonal. An example of a sequence of orthogonal jumps is +shown below. Starting from o the first jump goes along the +row to the right. The second jump then goes along the column direction +up. After this, the sequence cannot be continued as a jump has already +been made in both row and column direction.

+
+

OUTPUT +

+
- - 2
+- o 1
+- - -
+
+

All pixels reachable with one, or two jumps form the +2-jump neighborhood. The grid below illustrates the +pixels reachable from the centre pixel o with a single +jump, highlighted with a 1, and the pixels reachable with 2 +jumps with a 2.

+
+

OUTPUT +

+
2 1 2
+1 o 1
+2 1 2
+
+

We want to revisit our example image mask from above and apply the +two different neighborhood rules. With a single jump connectivity for +each pixel, we get two resulting objects, highlighted in the image with +A’s and B’s.

+
+

OUTPUT +

+
0 0 0 0 0 0 0 0
+0 A A 0 0 0 0 0
+0 A A 0 0 0 0 0
+0 0 0 B B B 0 0
+0 0 0 B B B B 0
+0 0 0 0 0 0 0 0
+
+

In the 1-jump version, only pixels that have direct neighbors along +rows or columns are considered connected. Diagonal connections are not +included in the 1-jump neighborhood. With two jumps, however, we only +get a single object A because pixels are also considered +connected along the diagonals.

+
+

OUTPUT +

+
0 0 0 0 0 0 0 0
+0 A A 0 0 0 0 0
+0 A A 0 0 0 0 0
+0 0 0 A A A 0 0
+0 0 0 A A A A 0
+0 0 0 0 0 0 0 0
+
+
+
+ +
+Challenge +
+

Object counting (optional, not included in +timing)

+
+

How many objects with 1 orthogonal jump, how many with 2 orthogonal +jumps?

+
+

OUTPUT +

+
0 0 0 0 0 0 0 0
+0 X 0 0 0 X X 0
+0 0 X 0 0 0 0 0
+0 X 0 X X X 0 0
+0 X 0 X X 0 0 0
+0 0 0 0 0 0 0 0
+
+

1 jump

+
  1. 1
  2. +
  3. 5
  4. +
  5. 2
  6. +
+
+
+
+
+ +
+
+
  1. 5
  2. +
+
+
+
+
+
+ +
+Challenge +
+

Object counting (optional, not included in +timing) (continued) +

+
+

2 jumps

+
  1. 2
  2. +
  3. 3
  4. +
  5. 5
  6. +
+
+
+
+
+ +
+
+
  1. 2
  2. +
+
+
+
+
+
+ +
+Callout +
+

Jumps and neighborhoods

+
+

We have just introduced how you can reach different neighboring +pixels by performing one or more orthogonal jumps. We have used the +terms 1-jump and 2-jump neighborhood. There is also a different way of +referring to these neighborhoods: the 4- and 8-neighborhood. With a +single jump you can reach four pixels from a given starting pixel. +Hence, the 1-jump neighborhood corresponds to the 4-neighborhood. When +two orthogonal jumps are allowed, eight pixels can be reached, so the +2-jump neighborhood corresponds to the 8-neighborhood.

+
+
+
+

Connected Component Analysis

+

In order to find the objects in an image, we want to employ an +operation that is called Connected Component Analysis (CCA). This +operation takes a binary image as an input. Usually, the +False value in this image is associated with background +pixels, and the True value indicates foreground, or object +pixels. Such an image can be produced, e.g., with thresholding. Given a +thresholded image, the connected component analysis produces a new +labeled image with integer pixel values. Pixels with the same +value, belong to the same object. scikit-image provides connected +component analysis in the function ski.measure.label(). Let +us add this function to the already familiar steps of thresholding an +image.

+

First, import the packages needed for this episode:

+
+

PYTHON +

+
import imageio.v3 as iio
+import ipympl
+import matplotlib.pyplot as plt
+import numpy as np
+import skimage as ski
+
+%matplotlib widget
+
+

In this episode, we will use the ski.measure.label +function to perform the CCA.

+

Next, we define a reusable Python function +connected_components:

+
+

PYTHON +

+
def connected_components(filename, sigma=1.0, t=0.5, connectivity=2):
+    # load the image
+    image = iio.imread(filename)
+    # convert the image to grayscale
+    gray_image = ski.color.rgb2gray(image)
+    # denoise the image with a Gaussian filter
+    blurred_image = ski.filters.gaussian(gray_image, sigma=sigma)
+    # mask the image according to threshold
+    binary_mask = blurred_image < t
+    # perform connected component analysis
+    labeled_image, count = ski.measure.label(binary_mask,
+                                                 connectivity=connectivity, return_num=True)
+    return labeled_image, count
+
+

The first four lines of code are familiar from the Thresholding episode.

+ +

Then we call the ski.measure.label function. This +function has one positional argument where we pass the +binary_mask, i.e., the binary image to work on. With the +optional argument connectivity, we specify the neighborhood +in units of orthogonal jumps. For example, by setting +connectivity=2 we will consider the 2-jump neighborhood +introduced above. The function returns a labeled_image +where each pixel has a unique value corresponding to the object it +belongs to. In addition, we pass the optional parameter +return_num=True to return the maximum label index as +count.

+
+
+ +
+Callout +
+

Optional parameters and return values

+
+

The optional parameter return_num changes the data type +that is returned by the function ski.measure.label. The +number of labels is only returned if return_num is +True. Otherwise, the function only returns the labeled image. +This means that we have to pay attention when assigning the return value +to a variable. If we omit the optional parameter return_num +or pass return_num=False, we can call the function as

+
+

PYTHON +

+
labeled_image = ski.measure.label(binary_mask)
+
+

If we pass return_num=True, the function returns a tuple +and we can assign it as

+
+

PYTHON +

+
labeled_image, count = ski.measure.label(binary_mask, return_num=True)
+
+

If we used the same assignment as in the first case, the variable +labeled_image would become a tuple, in which +labeled_image[0] is the image and +labeled_image[1] is the number of labels. This could cause +confusion if we assume that labeled_image only contains the +image and pass it to other functions. If you get an +AttributeError: 'tuple' object has no attribute 'shape' or +similar, check if you have assigned the return values consistently with +the optional parameters.

+
+
+
+

We can call the above function connected_components and +display the labeled image like so:

+
+

PYTHON +

+
labeled_image, count = connected_components(filename="data/shapes-01.jpg", sigma=2.0, t=0.9, connectivity=2)
+
+fig, ax = plt.subplots()
+ax.imshow(labeled_image)
+ax.set_axis_off();
+
+
+
+ +
+
+

If you are using an older version of Matplotlib you might get a +warning +UserWarning: Low image data range; displaying image with stretched contrast. +or just see a visually empty image.

+

What went wrong? When you hover over the image, the pixel values are +shown as numbers in the lower corner of the viewer. You can see that +some pixels have values different from 0, so they are not +actually all the same value. Let’s find out more by examining +labeled_image. Properties that might be interesting in this +context are dtype, the minimum and maximum value. We can +print them with the following lines:

+
+

PYTHON +

+
print("dtype:", labeled_image.dtype)
+print("min:", np.min(labeled_image))
+print("max:", np.max(labeled_image))
+
+

Examining the output can give us a clue why the image appears +empty.

+
+

OUTPUT +

+
dtype: int32
+min: 0
+max: 11
+
+

The dtype of labeled_image is +int32. This means that values in this image range from +-2 ** 31 to 2 ** 31 - 1. Those are really big +numbers. From this available space we only use the range from +0 to 11. When showing this image in the +viewer, it may squeeze the complete range into 256 gray values. +Therefore, the range of our numbers does not produce any visible +variation. One way to rectify this is to explicitly specify the data +range we want the colormap to cover:

+
+

PYTHON +

+
fig, ax = plt.subplots()
+ax.imshow(labeled_image, vmin=np.min(labeled_image), vmax=np.max(labeled_image))
+
+

Note this is the default behaviour for newer versions of +matplotlib.pyplot.imshow. Alternatively we could convert +the image to RGB and then display it.

+
+
+
+
+
+
+ +
+Callout +
+

Suppressing outputs in Jupyter Notebooks

+
+

We just used ax.set_axis_off(); to hide the axis from +the image for a visually cleaner figure. The semicolon is added to +supress the output(s) of the statement, in this case +the axis limits. This is specific to Jupyter Notebooks.

+
+
+
+

We can use the function ski.color.label2rgb() to convert +the 32-bit grayscale labeled image to standard RGB colour (recall that +we already used the ski.color.rgb2gray() function to +convert to grayscale). With ski.color.label2rgb(), all +objects are coloured according to a list of colours that can be +customised. We can use the following commands to convert and show the +image:

+
+

PYTHON +

+
# convert the label image to color image
+colored_label_image = ski.color.label2rgb(labeled_image, bg_label=0)
+
+fig, ax = plt.subplots()
+ax.imshow(colored_label_image)
+ax.set_axis_off();
+
+
Labeled objects
+
+ +
+Challenge +
+

How many objects are in that image (15 +min)

+
+

Now, it is your turn to practice. Using the function +connected_components, find two ways of printing out the +number of objects found in the image.

+

What number of objects would you expect to get?

+

How does changing the sigma and threshold +values influence the result?

+
+
+
+
+
+ +
+
+

As you might have guessed, the return value count +already contains the number of objects found in the image. So it can +simply be printed with

+
+

PYTHON +

+
print("Found", count, "objects in the image.")
+
+

But there is also a way to obtain the number of found objects from +the labeled image itself. Recall that all pixels that belong to a single +object are assigned the same integer value. The connected component +algorithm produces consecutive numbers. The background gets the value +0, the first object gets the value 1, the +second object the value 2, and so on. This means that by +finding the object with the maximum value, we also know how many objects +there are in the image. We can thus use the np.max function +from NumPy to find the maximum value that equals the number of found +objects:

+
+

PYTHON +

+
num_objects = np.max(labeled_image)
+print("Found", num_objects, "objects in the image.")
+
+

Invoking the function with sigma=2.0, and +threshold=0.9, both methods will print

+
+

OUTPUT +

+
Found 11 objects in the image.
+
+

Lowering the threshold will result in fewer objects. The higher the +threshold is set, the more objects are found. More and more background +noise gets picked up as objects. Larger sigmas produce binary masks with +less noise and hence a smaller number of objects. Setting sigma too high +bears the danger of merging objects.

+
+
+
+
+

You might wonder why the connected component analysis with +sigma=2.0, and threshold=0.9 finds 11 objects, +whereas we would expect only 7 objects. Where are the four additional +objects? With a bit of detective work, we can spot some small objects in +the image, for example, near the left border.

+
shapes-01.jpg mask detail

For us it is clear that these small spots are artifacts and not +objects we are interested in. But how can we tell the computer? One way +to calibrate the algorithm is to adjust the parameters for blurring +(sigma) and thresholding (t), but you may have +noticed during the above exercise that it is quite hard to find a +combination that produces the right output number. In some cases, +background noise gets picked up as an object. And with other parameters, +some of the foreground objects get broken up or disappear completely. +Therefore, we need other criteria to describe desired properties of the +objects that are found.

+

Morphometrics - Describe object features with numbers

+

Morphometrics is concerned with the quantitative analysis of objects +and considers properties such as size and shape. For the example of the +images with the shapes, our intuition tells us that the objects should +be of a certain size or area. So we could use a minimum area as a +criterion for when an object should be detected. To apply such a +criterion, we need a way to calculate the area of objects found by +connected components. Recall how we determined the root mass in the Thresholding episode by +counting the pixels in the binary mask. But here we want to calculate +the area of several objects in the labeled image. The scikit-image +library provides the function ski.measure.regionprops to +measure the properties of labeled regions. It returns a list of +RegionProperties that describe each connected region in the +images. The properties can be accessed using the attributes of the +RegionProperties data type. Here we will use the properties +"area" and "label". You can explore the +scikit-image documentation to learn about other properties +available.

+

We can get a list of areas of the labeled objects as follows:

+
+

PYTHON +

+
# compute object features and extract object areas
+object_features = ski.measure.regionprops(labeled_image)
+object_areas = [objf["area"] for objf in object_features]
+object_areas
+
+

This will produce the output

+
+

OUTPUT +

+
[318542, 1, 523204, 496613, 517331, 143, 256215, 1, 68, 338784, 265755]
+
+
+
+ +
+Challenge +
+

Plot a histogram of the object area +distribution (10 min)

+
+

Similar to how we determined a “good” threshold in the Thresholding episode, it is +often helpful to inspect the histogram of an object property. For +example, we want to look at the distribution of the object areas.

+
  1. Create and examine a histogram of the object areas +obtained with ski.measure.regionprops.
  2. +
  3. What does the histogram tell you about the objects?
  4. +
+
+
+
+
+ +
+
+

The histogram can be plotted with

+
+

PYTHON +

+
fig, ax = plt.subplots()
+ax.hist(object_areas)
+ax.set_xlabel("Area (pixels)")
+ax.set_ylabel("Number of objects");
+
+
Histogram of object areas

The histogram shows the number of objects (vertical axis) whose area +is within a certain range (horizontal axis). The height of the bars in +the histogram indicates the prevalence of objects with a certain area. +The whole histogram tells us about the distribution of object sizes in +the image. It is often possible to identify gaps between groups of bars +(or peaks if we draw the histogram as a continuous curve) that tell us +about certain groups in the image.

+

In this example, we can see that there are four small objects that +contain less than 50000 pixels. Then there is a group of four (1+1+2) +objects in the range between 200000 and 400000, and three objects with a +size around 500000. For our object count, we might want to disregard the +small objects as artifacts, i.e, we want to ignore the leftmost bar of +the histogram. We could use a threshold of 50000 as the minimum area to +count. In fact, the object_areas list already tells us that +there are fewer than 200 pixels in these objects. Therefore, it is +reasonable to require a minimum area of at least 200 pixels for a +detected object. In practice, finding the “right” threshold can be +tricky and usually involves an educated guess based on domain +knowledge.

+
+
+
+
+
+
+ +
+Challenge +
+

Filter objects by area (10 min)

+
+

Now we would like to use a minimum area criterion to obtain a more +accurate count of the objects in the image.

+
  1. Find a way to calculate the number of objects by only counting +objects above a certain area.
  2. +
+
+
+
+
+ +
+
+

One way to count only objects above a certain area is to first create +a list of those objects, and then take the length of that list as the +object count. This can be done as follows:

+
+

PYTHON +

+
min_area = 200
+large_objects = []
+for objf in object_features:
+    if objf["area"] > min_area:
+        large_objects.append(objf["label"])
+print("Found", len(large_objects), "objects!")
+
+

Another option is to use NumPy arrays to create the list of large +objects. We first create an array object_areas containing +the object areas, and an array object_labels containing the +object labels. The labels of the objects are also returned by +ski.measure.regionprops. We have already seen that we can +create boolean arrays using comparison operators. Here we can use +object_areas > min_area to produce an array that has the +same dimension as object_labels. It can then be used to +select the labels of objects whose area is greater than +min_area by indexing:

+
+

PYTHON +

+
object_areas = np.array([objf["area"] for objf in object_features])
+object_labels = np.array([objf["label"] for objf in object_features])
+large_objects = object_labels[object_areas > min_area]
+print("Found", len(large_objects), "objects!")
+
+

The advantage of using NumPy arrays is that for loops +and if statements in Python can be slow, and in practice +the first approach may not be feasible if the image contains a large +number of objects. In that case, NumPy array functions turn out to be +very useful because they are much faster.

+

In this example, we can also use the np.count_nonzero +function that we have seen earlier together with the > +operator to count the objects whose area is above +min_area.

+
+

PYTHON +

+
n = np.count_nonzero(object_areas > min_area)
+print("Found", n, "objects!")
+
+

For all three alternatives, the output is the same and gives the +expected count of 7 objects.

+
+
+
+
+
+
+ +
+Callout +
+

Using functions from NumPy and other Python +packages

+
+

Functions from Python packages such as NumPy are often more efficient +and require less code to write. It is a good idea to browse the +reference pages of numpy and skimage to look +for an availabe function that can solve a given task.

+
+
+
+
+
+ +
+Challenge +
+

Remove small objects (20 min)

+
+

We might also want to exclude (mask) the small objects when plotting +the labeled image.

+
  1. Enhance the connected_components function such that it +automatically removes objects that are below a certain area that is +passed to the function as an optional parameter.
  2. +
+
+
+
+
+ +
+
+

To remove the small objects from the labeled image, we change the +value of all pixels that belong to the small objects to the background +label 0. One way to do this is to loop over all objects and set the +pixels that match the label of the object to 0.

+
+

PYTHON +

+
for object_id, objf in enumerate(object_features, start=1):
+    if objf["area"] < min_area:
+        labeled_image[labeled_image == objf["label"]] = 0
+
+

Here NumPy functions can also be used to eliminate for +loops and if statements. Like above, we can create an array +of the small object labels with the comparison +object_areas < min_area. We can use another NumPy +function, np.isin, to set the pixels of all small objects +to 0. np.isin takes two arrays and returns a boolean array +with values True if the entry of the first array is found +in the second array, and False otherwise. This array can +then be used to index the labeled_image and set the entries +that belong to small objects to 0.

+
+

PYTHON +

+
object_areas = np.array([objf["area"] for objf in object_features])
+object_labels = np.array([objf["label"] for objf in object_features])
+small_objects = object_labels[object_areas < min_area]
+labeled_image[np.isin(labeled_image, small_objects)] = 0
+
+

An even more elegant way to remove small objects from the image is to +leverage the ski.morphology module. It provides a function +ski.morphology.remove_small_objects that does exactly what +we are looking for. It can be applied to a binary image and returns a +mask in which all objects smaller than min_area are +excluded, i.e, their pixel values are set to False. We can +then apply ski.measure.label to the masked image:

+
+

PYTHON +

+
object_mask = ski.morphology.remove_small_objects(binary_mask, min_size=min_area)
+labeled_image, n = ski.measure.label(object_mask,
+                                         connectivity=connectivity, return_num=True)
+
+

Using the scikit-image features, we can implement the +enhanced_connected_component as follows:

+
+

PYTHON +

+
def enhanced_connected_components(filename, sigma=1.0, t=0.5, connectivity=2, min_area=0):
+    image = iio.imread(filename)
+    gray_image = ski.color.rgb2gray(image)
+    blurred_image = ski.filters.gaussian(gray_image, sigma=sigma)
+    binary_mask = blurred_image < t
+    object_mask = ski.morphology.remove_small_objects(binary_mask, min_size=min_area)
+    labeled_image, count = ski.measure.label(object_mask,
+                                                 connectivity=connectivity, return_num=True)
+    return labeled_image, count
+
+

We can now call the function with a chosen min_area and +display the resulting labeled image:

+
+

PYTHON +

+
labeled_image, count = enhanced_connected_components(filename="data/shapes-01.jpg", sigma=2.0, t=0.9,
+                                                     connectivity=2, min_area=min_area)
+colored_label_image = ski.color.label2rgb(labeled_image, bg_label=0)
+
+fig, ax = plt.subplots()
+ax.imshow(colored_label_image)
+ax.set_axis_off();
+
+print("Found", count, "objects in the image.")
+
+
Objects filtered by area
+

OUTPUT +

+
Found 7 objects in the image.
+
+

Note that the small objects are “gone” and we obtain the correct +number of 7 objects in the image.

+
+
+
+
+
+
+ +
+Challenge +
+

Colour objects by area (optional, not included +in timing)

+
+

Finally, we would like to display the image with the objects coloured +according to the magnitude of their area. In practice, this can be used +with other properties to give visual cues of the object properties.

+
+
+
+
+
+ +
+
+

We already know how to get the areas of the objects from the +regionprops. We just need to insert a zero area value for +the background (to colour it like a zero size object). The background is +also labeled 0 in the labeled_image, so we +insert the zero area value in front of the first element of +object_areas with np.insert. Then we can +create a colored_area_image where we assign each pixel +value the area by indexing the object_areas with the label +values in labeled_image.

+
+

PYTHON +

+
object_areas = np.array([objf["area"] for objf in ski.measure.regionprops(labeled_image)])
+# prepend zero to object_areas array for background pixels
+object_areas = np.insert(0, obj=1, values=object_areas)
+# create image where the pixels in each object are equal to that object's area
+colored_area_image = object_areas[labeled_image]
+
+fig, ax = plt.subplots()
+im = ax.imshow(colored_area_image)
+cbar = fig.colorbar(im, ax=ax, shrink=0.85)
+cbar.ax.set_title("Area")
+ax.set_axis_off();
+
+
Objects colored by area
+
+ +
+Callout +
+
+

You may have noticed that in the solution, we have used the +labeled_image to index the array object_areas. +This is an example of advanced +indexing in NumPy The result is an array of the same shape as the +labeled_image whose pixel values are selected from +object_areas according to the object label. Hence the +objects will be colored by area when the result is displayed. Note that +advanced indexing with an integer array works slightly different than +the indexing with a Boolean array that we have used for masking. While +Boolean array indexing returns only the entries corresponding to the +True values of the index, integer array indexing returns an +array with the same shape as the index. You can read more about advanced +indexing in the NumPy +documentation.

+
+
+
+
+
+
+
+
+
+ +
+Key Points +
+
+
  • We can use ski.measure.label to find and label +connected objects in an image.
  • +
  • We can use ski.measure.regionprops to measure +properties of labeled objects.
  • +
  • We can use ski.morphology.remove_small_objects to mask +small objects and remove artifacts from an image.
  • +
  • We can display the labeled image to view the objects coloured by +label.
  • +
+
+
+
+
+ + +
+
+ + + diff --git a/instructor/09-challenges.html b/instructor/09-challenges.html new file mode 100644 index 000000000..3f3a46552 --- /dev/null +++ b/instructor/09-challenges.html @@ -0,0 +1,701 @@ + +Image Processing with Python: Capstone Challenge +
+
+ + + + + +
+
+

Capstone Challenge

+

Last updated on 2026-03-23 | + + Edit this page

+ + + +

Estimated time: 50 minutes

+ +
+ +
+ + + +
+

Overview

+
+
+
+
+

Questions

+
  • How can we automatically count bacterial colonies with image +analysis?
  • +
+
+
+
+
+
+

Objectives

+
  • Bring together everything you’ve learnt so far to count bacterial +colonies in 3 images.
  • +
+
+
+
+
+

In this episode, we will provide a final challenge for you to +attempt, based on all the skills you have acquired so far. This +challenge will be related to the shape of objects in images +(morphometrics).

+

Morphometrics: Bacteria Colony Counting

+

As mentioned in the workshop +introduction, your morphometric challenge is to determine how many +bacteria colonies are in each of these images:

+
Colony image 1
Colony image 2
Colony image 3

The image files can be found at data/colonies-01.tif, +data/colonies-02.tif, and +data/colonies-03.tif.

+
+
+ +
+Challenge +
+

Morphometrics for bacterial colonies

+
+

Write a Python program that uses scikit-image to count the number of +bacteria colonies in each image, and for each, produce a new image that +highlights the colonies. The image should look similar to this one:

+
Sample morphometric output

Additionally, print out the number of colonies for each image.

+

Use what you have learnt about histograms, thresholding and connected component analysis. +Try to put your code into a re-usable function, so that it can be +applied conveniently to any image file.

+
+
+
+
+
+ +
+
+

First, let’s work through the process for one image:

+
+

PYTHON +

+
import imageio.v3 as iio
+import ipympl
+import matplotlib.pyplot as plt
+import numpy as np
+import skimage as ski
+
+%matplotlib widget
+
+bacteria_image = iio.imread(uri="data/colonies-01.tif")
+
+# display the image
+fig, ax = plt.subplots()
+ax.imshow(bacteria_image)
+
+
Colony image 1

Next, we need to threshold the image to create a mask that covers +only the dark bacterial colonies. This is easier using a grayscale +image, so we convert it here:

+
+

PYTHON +

+
gray_bacteria = ski.color.rgb2gray(bacteria_image)
+
+# display the gray image
+fig, ax = plt.subplots()
+ax.imshow(gray_bacteria, cmap="gray")
+
+
Gray Colonies

Next, we blur the image and create a histogram:

+
+

PYTHON +

+
blurred_image = ski.filters.gaussian(gray_bacteria, sigma=1.0)
+histogram, bin_edges = np.histogram(blurred_image, bins=256, range=(0.0, 1.0))
+fig, ax = plt.subplots()
+ax.plot(bin_edges[0:-1], histogram)
+ax.set_title("Graylevel histogram")
+ax.set_xlabel("gray value")
+ax.set_ylabel("pixel count")
+ax.set_xlim(0, 1.0)
+
+
Histogram image

In this histogram, we see three peaks - the left one (i.e. the +darkest pixels) is our colonies, the central peak is the yellow/brown +culture medium in the dish, and the right one (i.e. the brightest +pixels) is the white image background. Therefore, we choose a threshold +that selects the small left peak:

+
+

PYTHON +

+
mask = blurred_image < 0.2
+fig, ax = plt.subplots()
+ax.imshow(mask, cmap="gray")
+
+
Colony mask image

This mask shows us where the colonies are in the image - but how can +we count how many there are? This requires connected component +analysis:

+
+

PYTHON +

+
labeled_image, count = ski.measure.label(mask, return_num=True)
+print(count)
+
+

Finally, we create the summary image of the coloured colonies on top +of the grayscale image:

+
+

PYTHON +

+
# color each of the colonies a different color
+colored_label_image = ski.color.label2rgb(labeled_image, bg_label=0)
+# give our grayscale image rgb channels, so we can add the colored colonies
+summary_image = ski.color.gray2rgb(gray_bacteria)
+summary_image[mask] = colored_label_image[mask]
+
+# plot overlay
+fig, ax = plt.subplots()
+ax.imshow(summary_image)
+
+
Sample morphometric output

Now that we’ve completed the task for one image, we need to repeat +this for the remaining two images. This is a good point to collect the +lines above into a re-usable function:

+
+

PYTHON +

+
def count_colonies(image_filename):
+    bacteria_image = iio.imread(image_filename)
+    gray_bacteria = ski.color.rgb2gray(bacteria_image)
+    blurred_image = ski.filters.gaussian(gray_bacteria, sigma=1.0)
+    mask = blurred_image < 0.2
+    labeled_image, count = ski.measure.label(mask, return_num=True)
+    print(f"There are {count} colonies in {image_filename}")
+
+    colored_label_image = ski.color.label2rgb(labeled_image, bg_label=0)
+    summary_image = ski.color.gray2rgb(gray_bacteria)
+    summary_image[mask] = colored_label_image[mask]
+    fig, ax = plt.subplots()
+    ax.imshow(summary_image)
+
+

Now we can do this analysis on all the images via a for loop:

+
+

PYTHON +

+
for image_filename in ["data/colonies-01.tif", "data/colonies-02.tif", "data/colonies-03.tif"]:
+    count_colonies(image_filename=image_filename)
+
+

Colony 1 outputColony 2 outputColony 3 output

+

You’ll notice that for the images with more colonies, the results +aren’t perfect. For example, some small colonies are missing, and there +are likely some small black spots being labelled incorrectly as +colonies. You could expand this solution to, for example, use an +automatically determined threshold for each image, which may fit each +better. Also, you could filter out colonies below a certain size (as we +did in the Connected +Component Analysis episode). You’ll also see that some touching +colonies are merged into one big colony. This could be fixed with more +complicated segmentation methods (outside of the scope of this lesson) +like watershed.

+
+
+
+
+
+
+ +
+Challenge +
+

Colony counting with minimum size and +automated threshold (optional, not included in timing)

+
+

Modify your function from the previous exercise for colony counting +to (i) exclude objects smaller than a specified size and (ii) use an +automated thresholding approach, e.g. Otsu, to mask the colonies.

+
+
+
+
+
+ +
+
+

Here is a modified function with the requested features. Note when +calculating the Otsu threshold we don’t include the very bright pixels +outside the dish.

+
+

PYTHON +

+
def count_colonies_enhanced(image_filename, sigma=1.0, min_colony_size=10, connectivity=2):
+    
+    bacteria_image = iio.imread(image_filename)
+    gray_bacteria = ski.color.rgb2gray(bacteria_image)
+    blurred_image = ski.filters.gaussian(gray_bacteria, sigma=sigma)
+    
+    # create mask excluding the very bright pixels outside the dish
+    # we dont want to include these when calculating the automated threshold
+    mask = blurred_image < 0.90
+    # calculate an automated threshold value within the dish using the Otsu method
+    t = ski.filters.threshold_otsu(blurred_image[mask])
+    # update mask to select pixels both within the dish and less than t
+    mask = np.logical_and(mask, blurred_image < t)
+    # remove objects smaller than specified area
+    mask = ski.morphology.remove_small_objects(mask, min_size=min_colony_size)
+    
+    labeled_image, count = ski.measure.label(mask, return_num=True)
+    print(f"There are {count} colonies in {image_filename}")
+    colored_label_image = ski.color.label2rgb(labeled_image, bg_label=0)
+    summary_image = ski.color.gray2rgb(gray_bacteria)
+    summary_image[mask] = colored_label_image[mask]
+    fig, ax = plt.subplots()
+    ax.imshow(summary_image)
+
+
+
+
+
+
+
+ +
+Key Points +
+
+
  • Using thresholding, connected component analysis and other tools we +can automatically segment images of bacterial colonies.
  • +
  • These methods are useful for many scientific problems, especially +those involving morphometrics.
  • +
+
+
+
+
+ +
+Discussion +
+

Where to go from here?

+
+

Take a look at our curated list of +resources for further publicly available courses, resources and +scientific literature around image processing and more.

+
+
+
+
+
+ + +
+
+ + + diff --git a/instructor/404.html b/instructor/404.html new file mode 100644 index 000000000..71e6b6468 --- /dev/null +++ b/instructor/404.html @@ -0,0 +1,421 @@ + +Image Processing with Python: Page not found +
+
+ + + + + +
+
+

Page not found

+ +

Our apologies!

+

We cannot seem to find the page you are looking for. Here are some +tips that may help:

+
  1. try going back to the previous +page or
  2. +
  3. navigate to any other page using the navigation bar on the +left.
  4. +
  5. if the URL ends with /index.html, try removing +that.
  6. +
  7. head over to the home page of this +lesson +
  8. +

If you came here from a link in this lesson, please contact the +lesson maintainers using the links at the foot of this page.

+
+
+ + +
+
+ + + diff --git a/instructor/CODE_OF_CONDUCT.html b/instructor/CODE_OF_CONDUCT.html new file mode 100644 index 000000000..68130f5d3 --- /dev/null +++ b/instructor/CODE_OF_CONDUCT.html @@ -0,0 +1,421 @@ + +Image Processing with Python: Contributor Code of Conduct +
+
+ + + + + +
+
+

Contributor Code of Conduct

+

Last updated on 2023-04-25 | + + Edit this page

+ + + + + +
+ +
+ + + +

As contributors and maintainers of this project, we pledge to follow +the The +Carpentries Code of Conduct.

+

Instances of abusive, harassing, or otherwise unacceptable behavior +may be reported by following our reporting +guidelines.

+ + + +
+
+ + +
+
+ + + diff --git a/instructor/LICENSE.html b/instructor/LICENSE.html new file mode 100644 index 000000000..68bc9c814 --- /dev/null +++ b/instructor/LICENSE.html @@ -0,0 +1,469 @@ + +Image Processing with Python: Licenses +
+
+ + + + + +
+
+

Licenses

+

Last updated on 2025-02-04 | + + Edit this page

+ + + + + +
+ +
+ + + +

Instructional Material

+

All Carpentries (Software Carpentry, Data Carpentry, and Library +Carpentry) instructional material is made available under the Creative Commons +Attribution license. The following is a human-readable summary of +(and not a substitute for) the full legal +text of the CC BY 4.0 license.

+

You are free:

+
  • to Share—copy and redistribute the material in any +medium or format
  • +
  • to Adapt—remix, transform, and build upon the +material
  • +

for any purpose, even commercially.

+

The licensor cannot revoke these freedoms as long as you follow the +license terms.

+

Under the following terms:

+
  • Attribution—You must give appropriate credit +(mentioning that your work is derived from work that is Copyright (c) +The Carpentries and, where practical, linking to https://carpentries.org/), provide a link to the +license, and indicate if changes were made. You may do so in any +reasonable manner, but not in any way that suggests the licensor +endorses you or your use.

  • +
  • No additional restrictions—You may not apply +legal terms or technological measures that legally restrict others from +doing anything the license permits. With the understanding +that:

  • +

Notices:

+
  • You do not have to comply with the license for elements of the +material in the public domain or where your use is permitted by an +applicable exception or limitation.
  • +
  • No warranties are given. The license may not give you all of the +permissions necessary for your intended use. For example, other rights +such as publicity, privacy, or moral rights may limit how you use the +material.
  • +

Software

+

Except where otherwise noted, the example programs and other software +provided by The Carpentries are made available under the OSI-approved MIT +license.

+

Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +“Software”), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions:

+

The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software.

+

THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

+

Trademark

+

“The Carpentries”, “Software Carpentry”, “Data Carpentry”, and +“Library Carpentry” and their respective logos are registered trademarks +of The Carpentries, Inc..

+
+
+ + +
+
+ + + diff --git a/instructor/aio.html b/instructor/aio.html new file mode 100644 index 000000000..a165fd115 --- /dev/null +++ b/instructor/aio.html @@ -0,0 +1,6303 @@ + + + + + +Image Processing with Python: All in One View + + + + + + + + + + + + +
+
+ + + + + + +
+
+

All in One View

+ +

Content from Introduction

+
+

Last updated on 2024-11-28 | + + Edit this page

+

Estimated time: 5 minutes

+
+ +
+
+

Overview

+
+
+
+
+

Questions

+
    +
  • What sort of scientific questions can we answer with image +processing / computer vision?
  • +
  • What are morphometric problems?
  • +
+
+
+
+
+
+
+

Objectives

+
    +
  • Recognise scientific questions that could be solved with image +processing / computer vision.
  • +
  • Recognise morphometric problems (those dealing with the number, +size, or shape of the objects in an image).
  • +
+
+
+
+
+
+

As computer systems have become faster and more powerful, and cameras +and other imaging systems have become commonplace in many other areas of +life, the need has grown for researchers to be able to process and +analyse image data. Considering the large volumes of data that can be +involved - high-resolution images that take up a lot of disk +space/virtual memory, and/or collections of many images that must be +processed together - and the time-consuming and error-prone nature of +manual processing, it can be advantageous or even necessary for this +processing and analysis to be automated as a computer program.

+

This lesson introduces an open source toolkit for processing image +data: the Python programming language and the scikit-image +(skimage) library. With careful experimental design, +Python code can be a powerful instrument in answering many different +kinds of questions.

+

Uses of Image Processing in Research +

+
+

Automated processing can be used to analyse many different properties +of an image, including the distribution and change in colours in the +image, the number, size, position, orientation, and shape of objects in +the image, and even - when combined with machine learning techniques for +object recognition - the type of objects in the image.

+

Some examples of image processing methods applied in research +include:

+ +

With this lesson, we aim to provide a thorough grounding in the +fundamental concepts and skills of working with image data in Python. +Most of the examples used in this lesson focus on one particular class +of image processing technique, morphometrics, but what you will +learn can be used to solve a much wider range of problems.

+

Morphometrics +

+
+

Morphometrics involves counting the number of objects in an image, +analyzing the size of the objects, or analyzing the shape of the +objects. For example, we might be interested in automatically counting +the number of bacterial colonies growing in a Petri dish, as shown in +this image:

+
Bacteria colony

We could use image processing to find the colonies, count them, and +then highlight their locations on the original image, resulting in an +image like this:

+
Colonies counted
+
+ +
+Callout +
+

Why write a program to do that?

+
+

Note that you can easily manually count the number of bacteria +colonies shown in the morphometric example above. Why should we learn +how to write a Python program to do a task we could easily perform with +our own eyes? There are at least two reasons to learn how to perform +tasks like these with Python and scikit-image:

+
    +
  1. What if there are many more bacteria colonies in the Petri dish? For +example, suppose the image looked like this:
  2. +
+
Bacteria colony

Manually counting the colonies in that image would present more of a +challenge. A Python program using scikit-image could count the number of +colonies more accurately, and much more quickly, than a human could.

+
    +
  1. What if you have hundreds, or thousands, of images to consider? +Imagine having to manually count colonies on several thousand images +like those above. A Python program using scikit-image could move through +all of the images in seconds; how long would a graduate student require +to do the task? Which process would be more accurate and +repeatable?
  2. +
+

As you can see, the simple image processing / computer vision +techniques you will learn during this workshop can be very valuable +tools for scientific research.

+
+
+
+

As we move through this workshop, we will learn image analysis +methods useful for many different scientific problems. These will be +linked together and applied to a real problem in the final +end-of-workshop capstone challenge.

+

Let’s get started, by learning some basics about how images are +represented and stored digitally.

+
+
+ +
+Key Points +
+
+
    +
  • Simple Python and scikit-image techniques can be used to solve +genuine image analysis problems.
  • +
  • Morphometric problems involve the number, shape, and / or size of +the objects in an image.
  • +
+
+
+
+

Content from Image Basics

+
+

Last updated on 2024-12-01 | + + Edit this page

+

Estimated time: 25 minutes

+
+ +
+
+

Overview

+
+
+
+
+

Questions

+
    +
  • How are images represented in digital format?
  • +
+
+
+
+
+
+
+

Objectives

+
    +
  • Define the terms bit, byte, kilobyte, megabyte, etc.
  • +
  • Explain how a digital image is composed of pixels.
  • +
  • Recommend using imageio (resp. scikit-image) for I/O (resp. image +processing) tasks.
  • +
  • Explain how images are stored in NumPy arrays.
  • +
  • Explain the left-hand coordinate system used in digital images.
  • +
  • Explain the RGB additive colour model used in digital images.
  • +
  • Explain the order of the three colour values in scikit-image +images.
  • +
  • Explain the characteristics of the BMP, JPEG, and TIFF image +formats.
  • +
  • Explain the difference between lossy and lossless compression.
  • +
  • Explain the advantages and disadvantages of compressed image +formats.
  • +
  • Explain what information could be contained in image metadata.
  • +
+
+
+
+
+
+

The images we see on hard copy, view with our electronic devices, or +process with our programs are represented and stored in the computer as +numeric abstractions, approximations of what we see with our eyes in the +real world. Before we begin to learn how to process images with Python +programs, we need to spend some time understanding how these +abstractions work.

+
+
+ +
+Callout +
+
+

Feel free to make use of the available cheat-sheet as a guide for +the rest of the course material. View it online, share it, or print the +PDF!

+
+
+
+

Pixels +

+
+

It is important to realise that images are stored as rectangular +arrays of hundreds, thousands, or millions of discrete “picture +elements,” otherwise known as pixels. Each pixel can be thought +of as a single square point of coloured light.

+

For example, consider this image of a maize seedling, with a square +area designated by a red box:

+
Original size image

Now, if we zoomed in close enough to see the pixels in the red box, +we would see something like this:

+
Enlarged image area

Note that each square in the enlarged image area - each pixel - is +all one colour, but that each pixel can have a different colour from its +neighbors. Viewed from a distance, these pixels seem to blend together +to form the image we see.

+

Real-world images are typically made up of a vast number of pixels, +and each of these pixels is one of potentially millions of colours. +While we will deal with pictures of such complexity in this lesson, +let’s start our exploration with just 15 pixels in a 5 x 3 matrix with 2 +colours, and work our way up to that complexity.

+
+
+ +
+Callout +
+

Matrices, arrays, images and pixels

+
+

A matrix is a mathematical concept - numbers evenly +arranged in a rectangle. This can be a two-dimensional rectangle, like +the shape of the screen you’re looking at now. Or it could be a +three-dimensional equivalent, a cuboid, or have even more dimensions, +but always keeping the evenly spaced arrangement of numbers. In +computing, an array refers to a structure in the +computer’s memory where data is stored in evenly spaced +elements. This is strongly analogous to a matrix. A +NumPy array is a type of variable (a simpler example of +a type is an integer). For our purposes, the distinction between +matrices and arrays is not important, we don’t really care how the +computer arranges our data in its memory. The important thing is that +the computer stores values describing the pixels in images, as arrays. +And the terms matrix and array will be used interchangeably.

+
+
+
+

Loading images +

+
+

As noted, images we want to analyze (process) with Python are loaded +into arrays. There are multiple ways to load images. In this lesson, we +use imageio, a Python library for reading (loading) and writing (saving) +image data, and more specifically its version 3. But, really, we could +use any image loader which would return a NumPy array.

+
+

PYTHON +

+
"""Python library for reading and writing images."""
+
+import imageio.v3 as iio
+
+

The v3 module of imageio (imageio.v3) is +imported as iio (see note in the next section). Version 3 +of imageio has the benefit of supporting nD (multidimensional) image +data natively (think of volumes, movies).

+

Let us load our image data from disk using the imread +function from the imageio.v3 module.

+
+

PYTHON +

+
eight = iio.imread(uri="data/eight.tif")
+print(type(eight))
+
+
+

OUTPUT +

+
<class 'numpy.ndarray'>
+
+

Note that, using the same image loader or a different one, we could +also read in remotely hosted data.

+
+
+ +
+Callout +
+

Why not use +skimage.io.imread()?

+
+

The scikit-image library has its own function to read an image, so +you might be asking why we don’t use it here. Actually, +skimage.io.imread() uses iio.imread() +internally when loading an image into Python. It is certainly something +you may use as you see fit in your own code. In this lesson, we use the +imageio library to read or write images, while scikit-image is dedicated +to performing operations on the images. Using imageio gives us more +flexibility, especially when it comes to handling metadata.

+
+
+
+
+
+ +
+Callout +
+

Beyond NumPy arrays

+
+

Beyond NumPy arrays, there exist other types of variables which are +array-like. Notably, pandas.DataFrame +and xarray.DataArray +can hold labeled, tabular data. These are not natively supported in +scikit-image, the scientific toolkit we use in this lesson for +processing image data. However, data stored in these types can be +converted to numpy.ndarray with certain assumptions (see +pandas.DataFrame.to_numpy() and +xarray.DataArray.data). Particularly, these conversions +ignore the sampling coordinates (DataFrame.index, +DataFrame.columns, or DataArray.coords), which +may result in misrepresented data, for instance, when the original data +points are irregularly spaced.

+
+
+
+

Working with pixels +

+
+

First, let us add the necessary imports:

+
+

PYTHON +

+
"""Python libraries for learning and performing image processing."""
+
+import ipympl
+import matplotlib.pyplot as plt
+import numpy as np
+import skimage as ski
+
+
+
+ +
+Callout +
+

Import statements in Python

+
+

In Python, the import statement is used to load +additional functionality into a program. This is necessary when we want +our code to do something more specialised, which cannot easily be +achieved with the limited set of basic tools and data structures +available in the default Python environment.

+

Additional functionality can be loaded as a single function or +object, a module defining several of these, or a library containing many +modules. You will encounter several different forms of +import statement.

+
+

PYTHON +

+
import skimage                 # form 1, load whole skimage library
+import skimage.draw            # form 2, load skimage.draw module only
+from skimage.draw import disk  # form 3, load only the disk function
+import skimage as ski          # form 4, load all of skimage into an object called ski
+
+
+
+ +
+
+

In the example above, form 1 loads the entire scikit-image library +into the program as an object. Individual modules of the library are +then available within that object, e.g., to access the disk +function used in the drawing episode, you +would write skimage.draw.disk().

+

Form 2 loads only the draw module of +skimage into the program. The syntax needed to use the +module remains unchanged: to access the disk function, we +would use the same function call as given for form 1.

+

Form 3 can be used to import only a specific function/class from a +library/module. Unlike the other forms, when this approach is used, the +imported function or class can be called by its name only, without +prefixing it with the name of the library/module from which it was +loaded, i.e., disk() instead of +skimage.draw.disk() using the example above. One hazard of +this form is that importing like this will overwrite any object with the +same name that was defined/imported earlier in the program, i.e., the +example above would replace any existing object called disk +with the disk function from skimage.draw.

+

Finally, the as keyword can be used when importing, to +define a name to be used as shorthand for the library/module being +imported. This name is referred to as an alias. Typically, using an +alias (such as np for the NumPy library) saves us a little +typing. You may see as combined with any of the other first +three forms of import statements.

+

Which form is used often depends on the size and number of additional +tools being loaded into the program.

+
+
+
+
+
+
+
+

Now that we have our libraries loaded, we will run a Jupyter Magic +Command that will ensure our images display in our Jupyter document with +pixel information that will help us more efficiently run commands later +in the session.

+
+

PYTHON +

+
%matplotlib widget
+
+

With that taken care of, let us display the image we have loaded, +using the imshow function from the +matplotlib.pyplot module.

+
+

PYTHON +

+
fig, ax = plt.subplots()
+ax.imshow(eight)
+
+
Image of 8

You might be thinking, “That does look vaguely like an eight, and I +see two colours but how can that be only 15 pixels”. The display of the +eight you see does use a lot more screen pixels to display our eight so +large, but that does not mean there is information for all those screen +pixels in the file. All those extra pixels are a consequence of our +viewer creating additional pixels through interpolation. It could have +just displayed it as a tiny image using only 15 screen pixels if the +viewer was designed differently.

+

While many image file formats contain descriptive metadata that can +be essential, the bulk of a picture file is just arrays of numeric +information that, when interpreted according to a certain rule set, +become recognizable as an image to us. Our image of an eight is no +exception, and imageio.v3 stored that image data in an +array of arrays making a 5 x 3 matrix of 15 pixels. We can demonstrate +that by calling on the shape property of our image variable and see the +matrix by printing our image variable to the screen.

+
+

PYTHON +

+
print(eight.shape)
+print(eight)
+
+
+

OUTPUT +

+
(5, 3)
+[[0. 0. 0.]
+ [0. 1. 0.]
+ [0. 0. 0.]
+ [0. 1. 0.]
+ [0. 0. 0.]]
+
+

Thus if we have tools that will allow us to manipulate these arrays +of numbers, we can manipulate the image. The NumPy library can be +particularly useful here, so let’s try that out using NumPy array +slicing. Notice that the default behavior of the imshow +function appended row and column numbers that will be helpful to us as +we try to address individual or groups of pixels. First let’s load +another copy of our eight, and then make it look like a zero.

+

To make it look like a zero, we need to change the number underlying +the centremost pixel to be 1. With the help of those row and column +headers, at this small scale we can determine the centre pixel is in row +labeled 2 and column labeled 1. Using array slicing, we can then address +and assign a new value to that position.

+
+

PYTHON +

+
zero = iio.imread(uri="data/eight.tif")
+zero[2, 1]= 1.0
+
+# The following line of code creates a new figure for imshow to use in displaying our output.
+fig, ax = plt.subplots()
+ax.imshow(zero)
+print(zero)
+
+
+

OUTPUT +

+
[[0. 0. 0.]
+ [0. 1. 0.]
+ [0. 1. 0.]
+ [0. 1. 0.]
+ [0. 0. 0.]]
+
+
Image of 0
+
+ +
+Callout +
+

Coordinate system

+
+

When we process images, we can access, examine, and / or change the +colour of any pixel we wish. To do this, we need some convention on how +to access pixels individually; a way to give each one a name, or an +address of a sort.

+

The most common manner to do this, and the one we will use in our +programs, is to assign a modified Cartesian coordinate system to the +image. The coordinate system we usually see in mathematics has a +horizontal x-axis and a vertical y-axis, like this:

+
Cartesian coordinate system

The modified coordinate system used for our images will have only +positive coordinates, the origin will be in the upper left corner +instead of the centre, and y coordinate values will get larger as they +go down instead of up, like this:

+
Image coordinate system

This is called a left-hand coordinate system. If you hold +your left hand in front of your face and point your thumb at the floor, +your extended index finger will correspond to the x-axis while your +thumb represents the y-axis.

+
Left-hand coordinate system

Until you have worked with images for a while, the most common +mistake that you will make with coordinates is to forget that y +coordinates get larger as they go down instead of up as in a normal +Cartesian coordinate system. Consequently, it may be helpful to think in +terms of counting down rows (r) for the y-axis and across columns (c) +for the x-axis. This can be especially helpful in cases where you need +to transpose image viewer data provided in x,y format to +y,x format. Thus, we will use cx and ry where +appropriate to help bridge these two approaches.

+
+
+
+
+
+ +
+Challenge +
+

Changing Pixel Values (5 min)

+
+

Load another copy of eight named five, and then change the value of +pixels so you have what looks like a 5 instead of an 8. Display the +image and print out the matrix as well.

+
+
+
+
+
+ +
+
+

There are many possible solutions, but one method would be . . .

+
+

PYTHON +

+
five = iio.imread(uri="data/eight.tif")
+five[1, 2] = 1.0
+five[3, 0] = 1.0
+fig, ax = plt.subplots()
+ax.imshow(five)
+print(five)
+
+
+

OUTPUT +

+
[[0. 0. 0.]
+ [0. 1. 1.]
+ [0. 0. 0.]
+ [1. 1. 0.]
+ [0. 0. 0.]]
+
+
Image of 5
+
+
+
+
+

More colours +

+
+

Up to now, we only had a 2 colour matrix, but we can have more if we +use other numbers or fractions. One common way is to use the numbers +between 0 and 255 to allow for 256 different colours or 256 different +levels of grey. Let’s try that out.

+
+

PYTHON +

+
# make a copy of eight
+three_colours = iio.imread(uri="data/eight.tif")
+
+# multiply the whole matrix by 128
+three_colours = three_colours * 128
+
+# set the middle row (index 2) to the value of 255.,
+# so you end up with the values 0., 128., and 255.
+three_colours[2, :] = 255.
+fig, ax = plt.subplots()
+ax.imshow(three_colours)
+print(three_colours)
+
+
Image of three colours

We now have 3 colours, but are they the three colours you expected? +They all appear to be on a continuum of dark purple on the low end and +yellow on the high end. This is a consequence of the default colour map +(cmap) in this library. You can think of a colour map as an association +or mapping of numbers to a specific colour. However, the goal here is +not to have one number for every possible colour, but rather to have a +continuum of colours that demonstrate relative intensity. In our +specific case here for example, 255 or the highest intensity is mapped +to yellow, and 0 or the lowest intensity is mapped to a dark purple. The +best colour map for your data will vary and there are many options built +in, but this default selection was not arbitrary. A lot of science went +into making this the default due to its robustness when it comes to how +the human mind interprets relative colour values, grey-scale +printability, and colour-blind friendliness (You can read more about +this default colour map in a +Matplotlib tutorial and an explanatory article by the +authors). Thus it is a good place to start, and you should change it +only with purpose and forethought. For now, let’s see how you can do +that using an alternative map you have likely seen before where it will +be even easier to see it as a mapped continuum of intensities: +greyscale.

+
+

PYTHON +

+
fig, ax = plt.subplots()
+ax.imshow(three_colours, cmap="gray")
+
+
Image in greyscale

Above we have exactly the same underlying data matrix, but in +greyscale. Zero maps to black, 255 maps to white, and 128 maps to medium +grey. Here we only have a single channel in the data and utilize a +grayscale color map to represent the luminance, or intensity of the data +and correspondingly this channel is referred to as the luminance +channel.

+

Even more colours +

+
+

This is all well and good at this scale, but what happens when we +instead have a picture of a natural landscape that contains millions of +colours. Having a one to one mapping of number to colour like this would +be inefficient and make adjustments and building tools to do so very +difficult. Rather than larger numbers, the solution is to have more +numbers in more dimensions. Storing the numbers in a multi-dimensional +matrix where each colour or property like transparency is associated +with its own dimension allows for individual contributions to a pixel to +be adjusted independently. This ability to manipulate properties of +groups of pixels separately will be key to certain techniques explored +in later chapters of this lesson. To get started let’s see an example of +how different dimensions of information combine to produce a set of +pixels using a 4 x 4 matrix with 3 dimensions for the colours red, +green, and blue. Rather than loading it from a file, we will generate +this example using NumPy.

+
+

PYTHON +

+
# set the random seed so we all get the same matrix
+pseudorandomizer = np.random.RandomState(2021)
+# create a 4 × 4 checkerboard of random colours
+checkerboard = pseudorandomizer.randint(0, 255, size=(4, 4, 3))
+# restore the default map as you show the image
+fig, ax = plt.subplots()
+ax.imshow(checkerboard)
+# display the arrays
+print(checkerboard)
+
+
+

OUTPUT +

+
[[[116  85  57]
+  [128 109  94]
+  [214  44  62]
+  [219 157  21]]
+
+ [[ 93 152 140]
+  [246 198 102]
+  [ 70  33 101]
+  [  7   1 110]]
+
+ [[225 124 229]
+  [154 194 176]
+  [227  63  49]
+  [144 178  54]]
+
+ [[123 180  93]
+  [120   5  49]
+  [166 234 142]
+  [ 71  85  70]]]
+
+
Image of checkerboard

Previously we had one number being mapped to one colour or intensity. +Now we are combining the effect of 3 numbers to arrive at a single +colour value. Let’s see an example of that using the blue square at the +end of the second row, which has the index [1, 3].

+
+

PYTHON +

+
# extract all the colour information for the blue square
+upper_right_square = checkerboard[1, 3, :]
+upper_right_square
+
+

This outputs: array([ 7, 1, 110]) The integers in order represent +Red, Green, and Blue. Looking at the 3 values and knowing how they map, +can help us understand why it is blue. If we divide each value by 255, +which is the maximum, we can determine how much it is contributing +relative to its maximum potential. Effectively, the red is at 7/255 or +2.8 percent of its potential, the green is at 1/255 or 0.4 percent, and +blue is 110/255 or 43.1 percent of its potential. So when you mix those +three intensities of colour, blue is winning by a wide margin, but the +red and green still contribute to make it a slightly different shade of +blue than 0,0,110 would be on its own.

+

These colours mapped to dimensions of the matrix may be referred to +as channels. It may be helpful to display each of these channels +independently, to help us understand what is happening. We can do that +by multiplying our image array representation with a 1d matrix that has +a one for the channel we want to keep and zeros for the rest.

+
+

PYTHON +

+
red_channel = checkerboard * [1, 0, 0]
+fig, ax = plt.subplots()
+ax.imshow(red_channel)
+
+
Image of red channel
+

PYTHON +

+
green_channel = checkerboard * [0, 1, 0]
+fig, ax = plt.subplots()
+ax.imshow(green_channel)
+
+
Image of green channel
+

PYTHON +

+
blue_channel = checkerboard * [0, 0, 1]
+fig, ax = plt.subplots()
+ax.imshow(blue_channel)
+
+
Image of blue channel

If we look at the upper [1, 3] square in all three figures, we can +see each of those colour contributions in action. Notice that there are +several squares in the blue figure that look even more intensely blue +than square [1, 3]. When all three channels are combined though, the +blue light of those squares is being diluted by the relative strength of +red and green being mixed in with them.

+

24-bit RGB colour +

+
+

This last colour model we used, known as the RGB (Red, Green, +Blue) model, is the most common.

+

As we saw, the RGB model is an additive colour model, which +means that the primary colours are mixed together to form other colours. +Most frequently, the amount of the primary colour added is represented +as an integer in the closed range [0, 255] as seen in the example. +Therefore, there are 256 discrete amounts of each primary colour that +can be added to produce another colour. The number of discrete amounts +of each colour, 256, corresponds to the number of bits used to hold the +colour channel value, which is eight (28=256). Since we have +three channels with 8 bits for each (8+8+8=24), this is called 24-bit +colour depth.

+

Any particular colour in the RGB model can be expressed by a triplet +of integers in [0, 255], representing the red, green, and blue channels, +respectively. A larger number in a channel means that more of that +primary colour is present.

+
+
+ +
+Challenge +
+

Thinking about RGB colours (5 min)

+
+

Suppose that we represent colours as triples (r, g, b), where each of +r, g, and b is an integer in [0, 255]. What colours are represented by +each of these triples? (Try to answer these questions without reading +further.)

+
    +
  1. (255, 0, 0)
  2. +
  3. (0, 255, 0)
  4. +
  5. (0, 0, 255)
  6. +
  7. (255, 255, 255)
  8. +
  9. (0, 0, 0)
  10. +
  11. (128, 128, 128)
  12. +
+
+
+
+
+
+ +
+
+
    +
  1. (255, 0, 0) represents red, because the red channel is maximised, +while the other two channels have the minimum values.
  2. +
  3. (0, 255, 0) represents green.
  4. +
  5. (0, 0, 255) represents blue.
  6. +
  7. (255, 255, 255) is a little harder. When we mix the maximum value of +all three colour channels, we see the colour white.
  8. +
  9. (0, 0, 0) represents the absence of all colour, or black.
  10. +
  11. (128, 128, 128) represents a medium shade of gray. Note that the +24-bit RGB colour model provides at least 254 shades of gray, rather +than only fifty.
  12. +
+

Note that the RGB colour model may run contrary to your experience, +especially if you have mixed primary colours of paint to create new +colours. In the RGB model, the lack of any colour is black, +while the maximum amount of each of the primary colours is +white. With physical paint, we might start with a white base, and then +add differing amounts of other paints to produce a darker shade.

+
+
+
+
+

After completing the previous challenge, we can look at some further +examples of 24-bit RGB colours, in a visual way. The image in the next +challenge shows some colour names, their 24-bit RGB triplet values, and +the colour itself.

+
+
+ +
+Challenge +
+

RGB colour table (optional, not included in +timing)

+
+
RGB colour table

We cannot really provide a complete table. To see why, answer this +question: How many possible colours can be represented with the 24-bit +RGB model?

+
+
+
+
+
+ +
+
+

There are 24 total bits in an RGB colour of this type, and each bit +can be on or off, and so there are 224 = 16,777,216 possible +colours with our additive, 24-bit RGB colour model.

+
+
+
+
+

Although 24-bit colour depth is common, there are other options. For +example, we might have 8-bit colour (3 bits for red and green, but only +2 for blue, providing 8 × 8 × 4 = 256 colours) or 16-bit colour (4 bits +for red, green, and blue, plus 4 more for transparency, providing 16 × +16 × 16 = 4096 colours, with 16 transparency levels each). There are +colour depths with more than eight bits per channel, but as the human +eye can only discern approximately 10 million different colours, these +are not often used.

+

If you are using an older or inexpensive laptop screen or LCD monitor +to view images, it may only support 18-bit colour, capable of displaying +64 × 64 × 64 = 262,144 colours. 24-bit colour images will be converted +in some manner to 18-bit, and thus the colour quality you see will not +match what is actually in the image.

+

We can combine our coordinate system with the 24-bit RGB colour model +to gain a conceptual understanding of the images we will be working +with. An image is a rectangular array of pixels, each with its own +coordinate. Each pixel in the image is a square point of coloured light, +where the colour is specified by a 24-bit RGB triplet. Such an image is +an example of raster graphics.

+

Image formats +

+
+

Although the images we will manipulate in our programs are +conceptualised as rectangular arrays of RGB triplets, they are not +necessarily created, stored, or transmitted in that format. There are +several image formats we might encounter, and we should know the basics +of at least of few of them. Some formats we might encounter, and their +file extensions, are shown in this table:

+ + + + + + + + + + + + + + + + + + + +
FormatExtension
Device-Independent Bitmap (BMP).bmp
Joint Photographic Experts Group (JPEG).jpg or .jpeg
Tagged Image File Format (TIFF).tif or .tiff

BMP +

+
+

The file format that comes closest to our preceding conceptualisation +of images is the Device-Independent Bitmap, or BMP, file format. BMP +files store raster graphics images as long sequences of binary-encoded +numbers that specify the colour of each pixel in the image. Since +computer files are one-dimensional structures, the pixel colours are +stored one row at a time. That is, the first row of pixels (those with +y-coordinate 0) are stored first, followed by the second row (those with +y-coordinate 1), and so on. Depending on how it was created, a BMP image +might have 8-bit, 16-bit, or 24-bit colour depth.

+

24-bit BMP images have a relatively simple file format, can be viewed +and loaded across a wide variety of operating systems, and have high +quality. However, BMP images are not compressed, resulting in +very large file sizes for any useful image resolutions.

+

The idea of image compression is important to us for two reasons: +first, compressed images have smaller file sizes, and are therefore +easier to store and transmit; and second, compressed images may not have +as much detail as their uncompressed counterparts, and so our programs +may not be able to detect some important aspect if we are working with +compressed images. Since compression is important to us, we should take +a brief detour and discuss the concept.

+

Image compression +

+
+

Before discussing additional formats, familiarity with image +compression will be helpful. Let’s delve into that subject with a +challenge. For this challenge, you will need to know about bits / bytes +and how those are used to express computer storage capacities. If you +already know, you can skip to the challenge below.

+
+
+ +
+Callout +
+

Bits and bytes

+
+

Before we talk specifically about images, we first need to understand +how numbers are stored in a modern digital computer. When we think of a +number, we do so using a decimal, or base-10 +place-value number system. For example, a number like 659 is 6 × +102 + 5 × 101 + 9 × 100. Each digit in +the number is multiplied by a power of 10, based on where it occurs, and +there are 10 digits that can occur in each position (0, 1, 2, 3, 4, 5, +6, 7, 8, 9).

+

In principle, computers could be constructed to represent numbers in +exactly the same way. But, the electronic circuits inside a computer are +much easier to construct if we restrict the numeric base to only two, +instead of 10. (It is easier for circuitry to tell the difference +between two voltage levels than it is to differentiate among 10 levels.) +So, values in a computer are stored using a binary, or +base-2 place-value number system.

+

In this system, each symbol in a number is called a bit +instead of a digit, and there are only two values for each bit (0 and +1). We might imagine a four-bit binary number, 1101. Using the same kind +of place-value expansion as we did above for 659, we see that 1101 = 1 × +23 + 1 × 22 + 0 × 21 + 1 × +20, which if we do the math is 8 + 4 + 0 + 1, or 13 in +decimal.

+

Internally, computers have a minimum number of bits that they work +with at a given time: eight. A group of eight bits is called a +byte. The amount of memory (RAM) and drive space our computers +have is quantified by terms like Megabytes (MB), Gigabytes (GB), and +Terabytes (TB). The following table provides more formal definitions for +these terms.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
UnitAbbreviationSize
KilobyteKB1024 bytes
MegabyteMB1024 KB
GigabyteGB1024 MB
TerabyteTB1024 GB
+
+
+
+
+
+ +
+Challenge +
+

BMP image size (optional, not included in +timing)

+
+

Imagine that we have a fairly large, but very boring image: a 5,000 × +5,000 pixel image composed of nothing but white pixels. If we used an +uncompressed image format such as BMP, with the 24-bit RGB colour model, +how much storage would be required for the file?

+
+
+
+
+
+ +
+
+

In such an image, there are 5,000 × 5,000 = 25,000,000 pixels, and 24 +bits for each pixel, leading to 25,000,000 × 24 = 600,000,000 bits, or +75,000,000 bytes (71.5MB). That is quite a lot of space for a very +uninteresting image!

+
+
+
+
+

Since image files can be very large, various compression +schemes exist for saving (approximately) the same information while +using less space. These compression techniques can be categorised as +lossless or lossy.

+
+

Lossless compression +

+

In lossless image compression, we apply some algorithm (i.e., a +computerised procedure) to the image, resulting in a file that is +significantly smaller than the uncompressed BMP file equivalent would +be. Then, when we wish to load and view or process the image, our +program reads the compressed file, and reverses the compression process, +resulting in an image that is identical to the original. +Nothing is lost in the process – hence the term “lossless.”

+

The general idea of lossless compression is to somehow detect long +patterns of bytes in a file that are repeated over and over, and then +assign a smaller bit pattern to represent the longer sample. Then, the +compressed file is made up of the smaller patterns, rather than the +larger ones, thus reducing the number of bytes required to save the +file. The compressed file also contains a table of the substituted +patterns and the originals, so when the file is decompressed it can be +made identical to the original before compression.

+

To provide you with a concrete example, consider the 71.5 MB white +BMP image discussed above. When put through the zip compression utility +on Microsoft Windows, the resulting .zip file is only 72 KB in size! +That is, the .zip version of the image is three orders of magnitude +smaller than the original, and it can be decompressed into a file that +is byte-for-byte the same as the original. Since the original is so +repetitious - simply the same colour triplet repeated 25,000,000 times - +the compression algorithm can dramatically reduce the size of the +file.

+

If you work with .zip or .gz archives, you are dealing with lossless +compression.

+
+
+

Lossy compression +

+

Lossy compression takes the original image and discards some of the +detail in it, resulting in a smaller file format. The goal is to only +throw away detail that someone viewing the image would not notice. Many +lossy compression schemes have adjustable levels of compression, so that +the image creator can choose the amount of detail that is lost. The more +detail that is sacrificed, the smaller the image files will be - but of +course, the detail and richness of the image will be lower as well.

+

This is probably fine for images that are shown on Web pages or +printed off on 4 × 6 photo paper, but may or may not be fine for +scientific work. You will have to decide whether the loss of image +quality and detail are important to your work, versus the space savings +afforded by a lossy compression format.

+

It is important to understand that once an image is saved in a lossy +compression format, the lost detail is just that - lost. I.e., unlike +lossless formats, given an image saved in a lossy format, there is no +way to reconstruct the original image in a byte-by-byte manner.

+
+

JPEG +

+
+

JPEG images are perhaps the most commonly encountered digital images +today. JPEG uses lossy compression, and the degree of compression can be +tuned to your liking. It supports 24-bit colour depth, and since the +format is so widely used, JPEG images can be viewed and manipulated +easily on all computing platforms.

+
+
+ +
+Challenge +
+

Examining actual image sizes (optional, not +included in timing)

+
+

Let us see the effects of image compression on image size with actual +images. The following script creates a square white image 5000 x 5000 +pixels, and then saves it as a BMP and as a JPEG image.

+
+

PYTHON +

+
dim = 5000
+
+img = np.zeros((dim, dim, 3), dtype="uint8")
+img.fill(255)
+
+iio.imwrite(uri="data/ws.bmp", image=img)
+iio.imwrite(uri="data/ws.jpg", image=img)
+
+

Examine the file sizes of the two output files, ws.bmp +and ws.jpg. Does the BMP image size match our previous +prediction? How about the JPEG?

+
+
+
+
+
+ +
+
+

The BMP file, ws.bmp, is 75,000,054 bytes, which matches +our prediction very nicely. The JPEG file, ws.jpg, is +392,503 bytes, two orders of magnitude smaller than the bitmap +version.

+
+
+
+
+
+
+ +
+Challenge +
+

Comparing lossless versus lossy compression +(optional, not included in timing)

+
+

Let us see a hands-on example of lossless versus lossy compression. +Open a terminal (or Windows PowerShell) and navigate to the +data/ directory. The two output images, ws.bmp +and ws.jpg, should still be in the directory, along with +another image, tree.jpg.

+

We can apply lossless compression to any file by using the +zip command. Recall that the ws.bmp file +contains 75,000,054 bytes. Apply lossless compression to this image by +executing the following command: zip ws.zip ws.bmp +(Compress-Archive ws.bmp ws.zip with PowerShell). This +command tells the computer to create a new compressed file, +ws.zip, from the original bitmap image. Execute a similar +command on the tree JPEG file: zip tree.zip tree.jpg +(Compress-Archive tree.jpg tree.zip with PowerShell).

+

Having created the compressed file, use the ls -l +command (dir with PowerShell) to display the contents of +the directory. How big are the compressed files? How do those compare to +the size of ws.bmp and tree.jpg? What can you +conclude from the relative sizes?

+
+
+
+
+
+ +
+
+

Here is a partial directory listing, showing the sizes of the +relevant files there:

+
+

OUTPUT +

+
-rw-rw-r--  1 diva diva   154344 Jun 18 08:32 tree.jpg
+-rw-rw-r--  1 diva diva   146049 Jun 18 08:53 tree.zip
+-rw-rw-r--  1 diva diva 75000054 Jun 18 08:51 ws.bmp
+-rw-rw-r--  1 diva diva    72986 Jun 18 08:53 ws.zip
+
+

We can see that the regularity of the bitmap image (remember, it is a +5,000 x 5,000 pixel image containing only white pixels) allows the +lossless compression scheme to compress the file quite effectively. On +the other hand, compressing tree.jpg does not create a much +smaller file; this is because the JPEG image was already in a compressed +format.

+
+
+
+
+

Here is an example showing how JPEG compression might impact image +quality. Consider this image of several maize seedlings (scaled down +here from 11,339 × 11,336 pixels in order to fit the display).

+
Original image

Now, let us zoom in and look at a small section of the label in the +original, first in the uncompressed format:

+
Enlarged, uncompressed

Here is the same area of the image, but in JPEG format. We used a +fairly aggressive compression parameter to make the JPEG, in order to +illustrate the problems you might encounter with the format.

+
Enlarged, compressed

The JPEG image is of clearly inferior quality. It has less colour +variation and noticeable pixelation. Quality differences become even +more marked when one examines the colour histograms for each image. A +histogram shows how often each colour value appears in an image. The +histograms for the uncompressed (left) and compressed (right) images are +shown below:

+
Uncompressed histogram

We learn how to make histograms such as these later on in the +workshop. The differences in the colour histograms are even more +apparent than in the images themselves; clearly the colours in the JPEG +image are different from the uncompressed version.

+

If the quality settings for your JPEG images are high (and the +compression rate therefore relatively low), the images may be of +sufficient quality for your work. It all depends on how much quality you +need, and what restrictions you have on image storage space. Another +consideration may be where the images are stored. For example, +if your images are stored in the cloud and therefore must be downloaded +to your system before you use them, you may wish to use a compressed +image format to speed up file transfer time.

+

PNG +

+
+

PNG images are well suited for storing diagrams. It uses a lossless +compression and is hence often used in web applications for +non-photographic images. The format is able to store RGB and plain +luminance (single channel, without an associated color) data, among +others. Image data is stored row-wise and then, per row, a simple +filter, like taking the difference of adjacent pixels, can be applied to +increase the compressability of the data. The filtered data is then +compressed in the next step and written out to the disk.

+

TIFF +

+
+

TIFF images are popular with publishers, graphics designers, and +photographers. TIFF images can be uncompressed, or compressed using +either lossless or lossy compression schemes, depending on the settings +used, and so TIFF images seem to have the benefits of both the BMP and +JPEG formats. The main disadvantage of TIFF images (other than the size +of images in the uncompressed version of the format) is that they are +not universally readable by image viewing and manipulation software.

+

Metadata +

+
+

JPEG and TIFF images support the inclusion of metadata in +images. Metadata is textual information that is contained within an +image file. Metadata holds information about the image itself, such as +when the image was captured, where it was captured, what type of camera +was used and with what settings, etc. We normally don’t see this +metadata when we view an image, but we can view it independently if we +wish to (see Accessing +Metadata, below). The important thing to be aware of at this +stage is that you cannot rely on the metadata of an image being fully +preserved when you use software to process that image. The image +reader/writer library that we use throughout this lesson, +imageio.v3, includes metadata when saving new images but +may fail to keep certain metadata fields. In any case, remember: +if metadata is important to you, take precautions to always +preserve the original files.

+
+
+ +
+Callout +
+

Accessing Metadata

+
+

imageio.v3 provides a way to display or explore the +metadata associated with an image. Metadata is served independently from +pixel data:

+
+

PYTHON +

+
# read metadata
+metadata = iio.immeta(uri="data/eight.tif")
+# display the format-specific metadata
+metadata
+
+
+

OUTPUT +

+
{'is_fluoview': False,
+ 'is_nih': False,
+ 'is_micromanager': False,
+ 'is_ome': False,
+ 'is_lsm': False,
+ 'is_reduced': False,
+ 'is_shaped': True,
+ 'is_stk': False,
+ 'is_tiled': False,
+ 'is_mdgel': False,
+ 'compression': <COMPRESSION.NONE: 1>,
+ 'predictor': 1,
+ 'is_mediacy': False,
+ 'description': '{"shape": [5, 3]}',
+ 'description1': '',
+ 'is_imagej': False,
+ 'software': 'tifffile.py',
+ 'resolution_unit': 1,
+ 'resolution': (1.0, 1.0, 'NONE')}
+
+

Many popular image editing programs have built-in metadata viewing +capabilities. A platform-independent open-source tool that allows users +to read, write, and edit metadata is ExifTool. It can handle a wide range of +file types and metadata formats but requires some technical knowledge to +be used effectively. Other software exists that can help you handle +metadata, e.g., Fiji and ImageMagick. You may want +to explore these options if you need to work with the metadata of your +images.

+
+
+
+

Summary of image formats used in this lesson +

+
+

The following table summarises the characteristics of the BMP, JPEG, +and TIFF image formats:

+ +++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FormatCompressionMetadataAdvantagesDisadvantages
BMPNoneNoneUniversally viewable, high qualityLarge file sizes
JPEGLossyYesUniversally viewable, smaller file sizeDetail may be lost
PNGLosslessYesUniversally viewable, open standard, smaller file +sizeMetadata less flexible than TIFF, RGB only
TIFFNone, lossy, or losslessYesHigh quality or smaller file sizeNot universally viewable
+
+
+ +
+Key Points +
+
+
    +
  • Digital images are represented as rectangular arrays of square +pixels.
  • +
  • Digital images use a left-hand coordinate system, with the origin in +the upper left corner, the x-axis running to the right, and the y-axis +running down. Some learners may prefer to think in terms of counting +down rows for the y-axis and across columns for the x-axis. Thus, we +will make an effort to allow for both approaches in our lesson +presentation.
  • +
  • Most frequently, digital images use an additive RGB model, with +eight bits for the red, green, and blue channels.
  • +
  • scikit-image images are stored as multi-dimensional NumPy +arrays.
  • +
  • In scikit-image images, the red channel is specified first, then the +green, then the blue, i.e., RGB.
  • +
  • Lossless compression retains all the details in an image, but lossy +compression results in loss of some of the original image detail.
  • +
  • BMP images are uncompressed, meaning they have high quality but also +that their file sizes are large.
  • +
  • JPEG images use lossy compression, meaning that their file sizes are +smaller, but image quality may suffer.
  • +
  • TIFF images can be uncompressed or compressed with lossy or lossless +compression.
  • +
  • Depending on the camera or sensor, various useful pieces of +information may be stored in an image file, in the image metadata.
  • +
+
+
+
+

Content from Working with scikit-image

+
+

Last updated on 2026-03-20 | + + Edit this page

+

Estimated time: 120 minutes

+
+ +
+
+

Overview

+
+
+
+
+

Questions

+
    +
  • How can the scikit-image Python computer vision library be used to +work with images?
  • +
+
+
+
+
+
+
+

Objectives

+
    +
  • Read and save images with imageio.
  • +
  • Display images with Matplotlib.
  • +
  • Resize images with scikit-image.
  • +
  • Perform simple image thresholding with NumPy array operations.
  • +
  • Extract sub-images using array slicing.
  • +
+
+
+
+
+
+

We have covered much of how images are represented in computer +software. In this episode we will learn some more methods for accessing +and changing digital images.

+

First, import the packages needed for this episode +

+
+
+

PYTHON +

+
import imageio.v3 as iio
+import ipympl
+import matplotlib.pyplot as plt
+import numpy as np
+import skimage as ski
+
+%matplotlib widget
+
+

Reading, displaying, and saving images +

+
+

Imageio provides intuitive functions for reading and writing (saving) +images. All of the popular image formats, such as BMP, PNG, JPEG, and +TIFF are supported, along with several more esoteric formats. Check the +Supported +Formats docs for a list of all formats. Matplotlib provides a large +collection of plotting utilities.

+

Let us examine a simple Python program to load, display, and save an +image to a different format. Here are the first few lines:

+
+

PYTHON +

+
"""Python program to open, display, and save an image."""
+# read image
+chair = iio.imread(uri="data/chair.jpg")
+
+

We use the iio.imread() function to read a JPEG image +entitled chair.jpg. Imageio reads the image, converts +it from JPEG into a NumPy array, and returns the array; we save the +array in a variable named chair.

+

Next, we will do something with the image:

+
+

PYTHON +

+
fig, ax = plt.subplots()
+ax.imshow(chair)
+
+

Once we have the image in the program, we first call +fig, ax = plt.subplots() so that we will have a fresh +figure with a set of axes independent from our previous calls. Next we +call ax.imshow() in order to display the image.

+

Now, we will save the image in another format:

+
+

PYTHON +

+
# save a new version in .tif format
+iio.imwrite(uri="data/chair.tif", image=chair)
+
+

The final statement in the program, +iio.imwrite(uri="data/chair.tif", image=chair), writes the +image to a file named chair.tif in the data/ +directory. The imwrite() function automatically determines +the type of the file, based on the file extension we provide. In this +case, the .tif extension causes the image to be saved as a +TIFF.

+
+
+ +
+Callout +
+

Metadata, revisited

+
+

Remember, as mentioned in the previous section, images saved with +imwrite() will not retain all metadata associated with the +original image that was loaded into Python! If the image metadata +is important to you, be sure to always keep an unchanged copy of +the original image!

+
+
+
+
+
+ +
+Callout +
+

Extensions do not always dictate file +type

+
+

The iio.imwrite() function automatically uses the file +type we specify in the file name parameter’s extension. Note that this +is not always the case. For example, if we are editing a document in +Microsoft Word, and we save the document as paper.pdf +instead of paper.docx, the file is not saved as a +PDF document.

+
+
+
+
+
+ +
+Callout +
+

Named versus positional arguments

+
+

When we call functions in Python, there are two ways we can specify +the necessary arguments. We can specify the arguments +positionally, i.e., in the order the parameters appear in the +function definition, or we can use named arguments.

+

For example, the iio.imwrite() function +definition specifies two parameters, the resource to save the image +to (e.g., a file name, an http address) and the image to write to disk. +So, we could save the chair image in the sample code above using +positional arguments like this:

+

iio.imwrite("data/chair.tif", image)

+

Since the function expects the first argument to be the file name, +there is no confusion about what "data/chair.jpg" means. +The same goes for the second argument.

+

The style we will use in this workshop is to name each argument, like +this:

+

iio.imwrite(uri="data/chair.tif", image=image)

+

This style will make it easier for you to learn how to use the +variety of functions we will cover in this workshop.

+
+
+
+
+
+ +
+Challenge +
+

Resizing an image (10 min)

+
+

Using the chair.jpg image located in the data folder, +write a Python script to read your image into a variable named +chair. Then, resize the image to 10 percent of its current +size using these lines of code:

+
+

PYTHON +

+
new_shape = (chair.shape[0] // 10, chair.shape[1] // 10, chair.shape[2])
+resized_chair = ski.transform.resize(image=chair, output_shape=new_shape)
+resized_chair = ski.util.img_as_ubyte(resized_chair)
+
+

As it is used here, the parameters to the +ski.transform.resize() function are the image to transform, +chair, the dimensions we want the new image to have, +new_shape.

+
+
+ +
+Callout +
+
+

Note that the pixel values in the new image are an approximation of +the original values and should not be confused with actual, observed +data. This is because scikit-image interpolates the pixel values when +reducing or increasing the size of an image. +ski.transform.resize has a number of optional parameters +that allow the user to control this interpolation. You can find more +details in the scikit-image +documentation.

+
+
+
+

Image files on disk are normally stored as whole numbers for space +efficiency, but transformations and other math operations often result +in conversion to floating point numbers. Using the +ski.util.img_as_ubyte() method converts it back to whole +numbers before we save it back to disk. If we don’t convert it before +saving, iio.imwrite() may not recognise it as image +data.

+

Next, write the resized image out to a new file named +resized.jpg in your data directory. Finally, use +ax.imshow() with each of your image variables to display +both images in your notebook. Don’t forget to use +fig, ax = plt.subplots() so you don’t overwrite the first +image with the second. Images may appear the same size in jupyter, but +you can see the size difference by comparing the scales for each. You +can also see the difference in file storage size on disk by hovering +your mouse cursor over the original and the new files in the Jupyter +file browser, using ls -l in your shell (dir +with Windows PowerShell), or viewing file sizes in the OS file browser +if it is configured so.

+
+
+
+
+
+ +
+
+

Here is what your Python script might look like.

+
+

PYTHON +

+
"""Python script to read an image, resize it, and save it under a different name."""
+
+# read in image
+chair = iio.imread(uri="data/chair.jpg")
+
+# resize the image
+new_shape = (chair.shape[0] // 10, chair.shape[1] // 10, chair.shape[2])
+resized_chair = ski.transform.resize(image=chair, output_shape=new_shape)
+resized_chair = ski.util.img_as_ubyte(resized_chair)
+
+# write out image
+iio.imwrite(uri="data/resized_chair.jpg", image=resized_chair)
+
+# display images
+fig, ax = plt.subplots()
+ax.imshow(chair)
+fig, ax = plt.subplots()
+ax.imshow(resized_chair)
+
+

The script resizes the data/chair.jpg image by a factor +of 10 in both dimensions, saves the result to the +data/resized_chair.jpg file, and displays original and +resized for comparision.

+
+
+
+
+

Manipulating pixels +

+
+

In the Image Basics +episode, we individually manipulated the colours of pixels by +changing the numbers stored in the image’s NumPy array. Let’s apply the +principles learned there along with some new principles to a real world +example.

+

Suppose we are interested in this maize root cluster image. We want +to be able to focus our program’s attention on the roots themselves, +while ignoring the black background.

+
Root cluster image

Since the image is stored as an array of numbers, we can simply look +through the array for pixel colour values that are less than some +threshold value. This process is called thresholding, and we +will see more powerful methods to perform the thresholding task in the Thresholding episode. Here, +though, we will look at a simple and elegant NumPy method for +thresholding. Let us develop a program that keeps only the pixel colour +values in an image that have value greater than or equal to 128. This +will keep the pixels that are brighter than half of “full brightness”, +i.e., pixels that do not belong to the black background.

+

We will start by reading the image and displaying it.

+
+
+ +
+Callout +
+

Loading images with imageio: Read-only +arrays

+
+

When loading an image with imageio, in certain situations the image +is stored in a read-only array. If you attempt to manipulate the pixels +in a read-only array, you will receive an error message +ValueError: assignment destination is read-only. In order +to make the image array writeable, we can create a copy with +image = np.array(image) before manipulating the pixel +values.

+
+
+
+
+

PYTHON +

+
"""Python script to ignore low intensity pixels in an image."""
+
+# read input image
+maize_roots = iio.imread(uri="data/maize-root-cluster.jpg")
+maize_roots = np.array(maize_roots)
+
+# display original image
+fig, ax = plt.subplots()
+ax.imshow(maize_roots)
+
+

Now we can threshold the image and display the result.

+
+

PYTHON +

+
# keep only high-intensity pixels
+maize_roots[maize_roots < 128] = 0
+
+# display modified image
+fig, ax = plt.subplots()
+ax.imshow(maize_roots)
+
+

The NumPy command to ignore all low-intensity pixels is +roots[roots < 128] = 0. Every pixel colour value in the +whole 3-dimensional array with a value less that 128 is set to zero. In +this case, the result is an image in which the extraneous background +detail has been removed.

+
Thresholded root image

Converting colour images to grayscale +

+
+

It is often easier to work with grayscale images, which have a single +channel, instead of colour images, which have three channels. +scikit-image offers the function ski.color.rgb2gray() to +achieve this. This function adds up the three colour channels in a way +that matches human colour perception, see the +scikit-image documentation for details. It returns a grayscale image +with floating point values in the range from 0 to 1. We can use the +function ski.util.img_as_ubyte() in order to convert it +back to the original data type and the data range back 0 to 255. Note +that it is often better to use image values represented by floating +point values, because using floating point numbers is numerically more +stable.

+
+
+ +
+Callout +
+

Colour and color +

+
+

The Carpentries generally prefers UK English spelling, which is why +we use “colour” in the explanatory text of this lesson. However, +scikit-image contains many modules and functions that include the US +English spelling, color. The exact spelling matters here, +e.g. you will encounter an error if you try to run +ski.colour.rgb2gray(). To account for this, we will use the +US English spelling, color, in example Python code +throughout the lesson. You will encounter a similar approach with +“centre” and center.

+
+
+
+
+

PYTHON +

+
"""Python script to load a color image as grayscale."""
+
+# read input image
+chair = iio.imread(uri="data/chair.jpg")
+
+# display original image
+fig, ax = plt.subplots()
+ax.imshow(chair)
+
+# convert to grayscale and display
+gray_chair = ski.color.rgb2gray(chair)
+fig, ax = plt.subplots()
+ax.imshow(gray_chair, cmap="gray")
+
+

We can also load colour images as grayscale directly by passing the +argument mode="L" to iio.imread().

+
+

PYTHON +

+
"""Python script to load a color image as grayscale."""
+
+# read input image, based on filename parameter
+gray_chair = iio.imread(uri="data/chair.jpg", mode="L")
+
+# display grayscale image
+fig, ax = plt.subplots()
+ax.imshow(gray_chair, cmap="gray")
+
+

The first argument to iio.imread() is the filename of +the image. The second argument mode="L" determines the type +and range of the pixel values in the image (e.g., an 8-bit pixel has a +range of 0-255). This argument is forwarded to the pillow +backend, a Python imaging library for which mode “L” means 8-bit pixels +and single-channel (i.e., grayscale). The backend used by +iio.imread() may be specified as an optional argument: to +use pillow, you would pass plugin="pillow". If +the backend is not specified explicitly, iio.imread() +determines the backend to use based on the image type.

+
+
+ +
+Callout +
+

Loading images with imageio: Pixel type and +depth

+
+

When loading an image with mode="L", the pixel values +are stored as 8-bit integer numbers that can take values in the range +0-255. However, pixel values may also be stored with other types and +ranges. For example, some scikit-image functions return the pixel values +as floating point numbers in the range 0-1. The type and range of the +pixel values are important for the colorscale when plotting, and for +masking and thresholding images as we will see later in the lesson. If +you are unsure about the type of the pixel values, you can inspect it +with print(image.dtype). For the example above, you should +find that it is dtype('uint8') indicating 8-bit integer +numbers.

+
+
+
+
+
+ +
+Challenge +
+

Keeping only low intensity pixels (10 +min)

+
+

A little earlier, we showed how we could use Python and scikit-image +to turn on only the high intensity pixels from an image, while turning +all the low intensity pixels off. Now, you can practice doing the +opposite - keeping all the low intensity pixels while changing the high +intensity ones.

+

The file data/sudoku.png is an RGB image of a sudoku +puzzle:

+
Su-Do-Ku puzzle

Your task is to load the image in grayscale format and turn all of +the bright pixels in the image to a light gray colour. In other words, +mask the bright pixels that have a pixel value greater than, say, 192 +and set their value to 192 (the value 192 is chosen here because it +corresponds to 75% of the range 0-255 of an 8-bit pixel). The results +should look like this:

+
Modified Su-Do-Ku puzzle

Hint: the cmap, vmin, and +vmax parameters of matplotlib.pyplot.imshow +will be needed to display the modified image as desired. See the Matplotlib +documentation for more details on cmap, +vmin, and vmax.

+
+
+
+
+
+ +
+
+

First, load the image file data/sudoku.png as a +grayscale image. Note we may want to create a copy of the image array to +avoid modifying our original variable and also because +imageio.v3.imread sometimes returns a non-writeable +image.

+
+

PYTHON +

+
sudoku = iio.imread(uri="data/sudoku.png", mode="L")
+sudoku_gray_background = np.array(sudoku)
+
+

Then change all bright pixel values greater than 192 to 192:

+
+

PYTHON +

+
sudoku_gray_background[sudoku_gray_background > 192] = 192
+
+

Finally, display the original and modified images side by side. Note +that we have to specify vmin=0 and vmax=255 as +the range of the colorscale because it would otherwise automatically +adjust to the new range 0-192.

+
+

PYTHON +

+
fig, ax = plt.subplots(ncols=2)
+ax[0].imshow(sudoku, cmap="gray", vmin=0, vmax=255)
+ax[1].imshow(sudoku_gray_background, cmap="gray", vmin=0, vmax=255)
+
+
+
+
+
+
+
+ +
+Callout +
+

Plotting single channel images (cmap, vmin, +vmax)

+
+

Compared to a colour image, a grayscale image contains only a single +intensity value per pixel. When we plot such an image with +ax.imshow, Matplotlib uses a colour map, to assign each +intensity value a colour. The default colour map is called “viridis” and +maps low values to purple and high values to yellow. We can instruct +Matplotlib to map low values to black and high values to white instead, +by calling ax.imshow with cmap="gray". The +documentation contains an overview of pre-defined colour maps.

+

Furthermore, Matplotlib determines the minimum and maximum values of +the colour map dynamically from the image, by default. That means that +in an image where the minimum is 64 and the maximum is 192, those values +will be mapped to black and white respectively (and not dark gray and +light gray as you might expect). If there are defined minimum and +maximum vales, you can specify them via vmin and +vmax to get the desired output.

+

If you forget about this, it can lead to unexpected results. Try +removing the vmax parameter from the sudoku challenge +solution and see what happens.

+
+
+
+

Access via slicing +

+
+

As noted in the previous lesson scikit-image images are stored as +NumPy arrays, so we can use array slicing to select rectangular areas of +an image. Then, we can save the selection as a new image, change the +pixels in the image, and so on. It is important to remember that +coordinates are specified in (ry, cx) order and that colour +values are specified in (r, g, b) order when doing these +manipulations.

+

Consider this image of a whiteboard, and suppose that we want to +create a sub-image with just the portion that says “odd + even = odd,” +along with the red box that is drawn around the words.

+
Whiteboard image

Using matplotlib.pyplot.imshow we can determine the +coordinates of the corners of the area we wish to extract by hovering +the mouse near the points of interest and noting the coordinates +(remember to run %matplotlib widget first if you haven’t +already). If we do that, we might settle on a rectangular area with an +upper-left coordinate of (135, 60) and a lower-right coordinate +of (480, 150), as shown in this version of the whiteboard +picture:

+
Whiteboard coordinates

Note that the coordinates in the preceding image are specified in +(cx, ry) order. Now if our entire whiteboard image is stored as +a NumPy array named image, we can create a new image of the +selected region with a statement like this:

+

clip = image[60:151, 135:481, :]

+

Our array slicing specifies the range of y-coordinates or rows first, +60:151, and then the range of x-coordinates or columns, +135:481. Note we go one beyond the maximum value in each +dimension, so that the entire desired area is selected. The third part +of the slice, :, indicates that we want all three colour +channels in our new image.

+

A script to create the subimage would start by loading the image:

+
+

PYTHON +

+
"""Python script demonstrating image modification and creation via NumPy array slicing."""
+
+# load and display original image
+board = iio.imread(uri="data/board.jpg")
+board = np.array(board)
+fig, ax = plt.subplots()
+ax.imshow(board)
+
+

Then we use array slicing to create a new image with our selected +area and then display the new image.

+
+

PYTHON +

+
# extract, display, and save sub-image
+clipped_board = board[60:151, 135:481, :]
+fig, ax = plt.subplots()
+ax.imshow(clipped_board)
+iio.imwrite(uri="data/clipped_board.tif", image=clipped_board)
+
+

We can also change the values in an image, as shown next.

+
+

PYTHON +

+
# replace clipped area with sampled color
+color = board[330, 90]
+board[60:151, 135:481] = color
+fig, ax = plt.subplots()
+ax.imshow(board)
+
+

First, we sample a single pixel’s colour at a particular location of +the image, saving it in a variable named color, which +creates a 1 × 1 × 3 NumPy array with the blue, green, and red colour +values for the pixel located at (ry = 330, cx = 90). Then, with +the img[60:151, 135:481] = color command, we modify the +image in the specified area. From a NumPy perspective, this changes all +the pixel values within that range to array saved in the +color variable. In this case, the command “erases” that +area of the whiteboard, replacing the words with a beige colour, as +shown in the final image produced by the program:

+
"Erased" whiteboard
+
+ +
+Challenge +
+

Practicing with slices (10 min - optional, not +included in timing)

+
+

Using the techniques you just learned, write a script that creates, +displays, and saves a sub-image containing only the plant and its roots +from “data/maize-root-cluster.jpg”

+
+
+
+
+
+ +
+
+

Here is the completed Python program to select only the plant and +roots in the image.

+
+

PYTHON +

+
"""Python script to extract a sub-image containing only the plant and roots in an existing image."""
+
+# load and display original image
+maize_roots = iio.imread(uri="data/maize-root-cluster.jpg")
+fig, ax = plt.subplots()
+ax.imshow(maize_roots)
+
+# extract and display sub-image
+clipped_maize = maize_roots[0:400, 275:550, :]
+fig, ax = plt.subplots()
+ax.imshow(clipped_maize)
+
+
+# save sub-image
+iio.imwrite(uri="data/clipped_maize.jpg", image=clipped_maize)
+
+
+
+
+
+
+
+ +
+Key Points +
+
+
    +
  • Images are read from disk with the iio.imread() +function.
  • +
  • We create a window that automatically scales the displayed image +with Matplotlib and calling imshow() on the global figure +object.
  • +
  • Colour images can be transformed to grayscale using +ski.color.rgb2gray() or, in many cases, be read as +grayscale directly by passing the argument mode="L" to +iio.imread().
  • +
  • We can resize images with the ski.transform.resize() +function.
  • +
  • NumPy array commands, such as +image[image < 128] = 0, can be used to manipulate the +pixels of an image.
  • +
  • Array slicing can be used to extract sub-images or modify areas of +images, e.g., clip = image[60:150, 135:480, :].
  • +
  • Metadata is not retained when images are loaded as NumPy arrays +using iio.imread().
  • +
+
+
+
+

Content from Drawing and Bitwise Operations

+
+

Last updated on 2026-03-20 | + + Edit this page

+

Estimated time: 90 minutes

+
+ +
+
+

Overview

+
+
+
+
+

Questions

+
    +
  • How can we draw on scikit-image images and use bitwise operations +and masks to select certain parts of an image?
  • +
+
+
+
+
+
+
+

Objectives

+
    +
  • Create a blank, black scikit-image image.
  • +
  • Draw rectangles and other shapes on scikit-image images.
  • +
  • Explain how a white shape on a black background can be used as a +mask to select specific parts of an image.
  • +
  • Use bitwise operations to apply a mask to an image.
  • +
+
+
+
+
+
+

The next series of episodes covers a basic toolkit of scikit-image +operators. With these tools, we will be able to create programs to +perform simple analyses of images based on changes in colour or +shape.

+

First, import the packages needed for this episode +

+
+
+

PYTHON +

+
import imageio.v3 as iio
+import ipympl
+import matplotlib.pyplot as plt
+import numpy as np
+import skimage as ski
+
+%matplotlib widget
+
+

Here, we import the same packages as earlier in the lesson.

+

Drawing on images +

+
+

Often we wish to select only a portion of an image to analyze, and +ignore the rest. Creating a rectangular sub-image with slicing, as we +did in the Working with +scikit-image episode is one option for simple cases. Another +option is to create another special image, of the same size as the +original, with white pixels indicating the region to save and black +pixels everywhere else. Such an image is called a mask. In +preparing a mask, we sometimes need to be able to draw a shape - a +circle or a rectangle, say - on a black image. scikit-image provides +tools to do that.

+

Consider this image of maize seedlings:

+
Maize seedlings

Now, suppose we want to analyze only the area of the image containing +the roots themselves; we do not care to look at the kernels, or anything +else about the plants. Further, we wish to exclude the frame of the +container holding the seedlings as well. Hovering over the image with +our mouse, could tell us that the upper-left coordinate of the sub-area +we are interested in is (44, 357), while the lower-right +coordinate is (720, 740). These coordinates are shown in +(x, y) order.

+

A Python program to create a mask to select only that area of the +image would start with a now-familiar section of code to open and +display the original image:

+
+

PYTHON +

+
# Load and display the original image
+maize_seedlings = iio.imread(uri="data/maize-seedlings.tif")
+
+fig, ax = plt.subplots()
+ax.imshow(maize_seedlings)
+
+

We load and display the initial image in the same way we have done +before.

+

NumPy allows indexing of images/arrays with “boolean” arrays of the +same size. Indexing with a boolean array is also called mask indexing. +The “pixels” in such a mask array can only take two values: +True or False. When indexing an image with +such a mask, only pixel values at positions where the mask is +True are accessed. But first, we need to generate a mask +array of the same size as the image. Luckily, the NumPy library provides +a function to create just such an array. The next section of code shows +how:

+
+

PYTHON +

+
# Create the basic mask
+mask = np.ones(shape=maize_seedlings.shape[0:2], dtype="bool")
+
+

The first argument to the ones() function is the shape +of the original image, so that our mask will be exactly the same size as +the original. Notice, that we have only used the first two indices of +our shape. We omitted the channel dimension. Indexing with such a mask +will change all channel values simultaneously. The second argument, +dtype = "bool", indicates that the elements in the array +should be booleans - i.e., values are either True or +False. Thus, even though we use np.ones() to +create the mask, its pixel values are in fact not 1 but +True. You could check this, e.g., by +print(mask[0, 0]).

+

Next, we draw a filled, rectangle on the mask:

+
+

PYTHON +

+
# Draw filled rectangle on the mask image
+rr, cc = ski.draw.rectangle(start=(357, 44), end=(740, 720))
+mask[rr, cc] = False
+
+# Display mask image
+fig, ax = plt.subplots()
+ax.imshow(mask, cmap="gray")
+
+

Here is what our constructed mask looks like: Maize image mask

+

The parameters of the rectangle() function +(357, 44) and (740, 720), are the coordinates +of the upper-left (start) and lower-right +(end) corners of a rectangle in (ry, cx) order. +The function returns the rectangle as row (rr) and column +(cc) coordinate arrays.

+
+
+ +
+Callout +
+

Check the documentation!

+
+

When using an scikit-image function for the first time - or the fifth +time - it is wise to check how the function is used, via the scikit-image +documentation or other usage examples on programming-related sites +such as Stack Overflow. Basic +information about scikit-image functions can be found interactively in +Python, via commands like help(ski) or +help(ski.draw.rectangle). Take notes in your lab notebook. +And, it is always wise to run some test code to verify that the +functions your program uses are behaving in the manner you intend.

+
+
+
+
+
+ +
+Callout +
+

Variable naming conventions!

+
+

You may have wondered why we called the return values of the +rectangle function rr and cc?! You may have +guessed that r is short for row and +c is short for column. However, the rectangle +function returns mutiple rows and columns; thus we used a convention of +doubling the letter r to rr (and +c to cc) to indicate that those are multiple +values. In fact it may have even been clearer to name those variables +rows and columns; however this would have been +also much longer. Whatever you decide to do, try to stick to some +already existing conventions, such that it is easier for other people to +understand your code.

+
+
+
+
+
+ +
+Challenge +
+

Other drawing operations (15 min)

+
+

There are other functions for drawing on images, in addition to the +ski.draw.rectangle() function. We can draw circles, lines, +text, and other shapes as well. These drawing functions may be useful +later on, to help annotate images that our programs produce. Practice +some of these functions here.

+

Circles can be drawn with the ski.draw.disk() function, +which takes two parameters: the (ry, cx) point of the centre of the +circle, and the radius of the circle. There is an optional +shape parameter that can be supplied to this function. It +will limit the output coordinates for cases where the circle dimensions +exceed the ones of the image.

+

Lines can be drawn with the ski.draw.line() function, +which takes four parameters: the (ry, cx) coordinate of one end of the +line, and the (ry, cx) coordinate of the other end of the line.

+

Other drawing functions supported by scikit-image can be found in the +scikit-image reference pages.

+

First let’s make an empty, black image with a size of 800x600 pixels. +Recall that a colour image has three channels for the colours red, +green, and blue (RGB, cf. Image +Basics). Hence we need to create a 3D array of shape +(600, 800, 3) where the last dimension represents the RGB +colour channels.

+
+

PYTHON +

+
# create the black canvas
+canvas = np.zeros(shape=(600, 800, 3), dtype="uint8")
+
+

Now your task is to draw some other coloured shapes and lines on the +image, perhaps something like this:

+
Sample shapes
+
+
+
+
+
+ +
+
+

Drawing a circle:

+
+

PYTHON +

+
# Draw a blue circle with centre (200, 300) in (ry, cx) coordinates, and radius 100
+rr, cc = ski.draw.disk(center=(200, 300), radius=100, shape=canvas.shape[0:2])
+canvas[rr, cc] = (0, 0, 255)
+
+

Drawing a line:

+
+

PYTHON +

+
# Draw a green line from (400, 200) to (500, 700) in (ry, cx) coordinates
+rr, cc = ski.draw.line(r0=400, c0=200, r1=500, c1=700)
+canvas[rr, cc] = (0, 255, 0)
+
+
+

PYTHON +

+
# Display the image
+fig, ax = plt.subplots()
+ax.imshow(canvas)
+
+

We could expand this solution, if we wanted, to draw rectangles, +circles and lines at random positions within our black canvas. To do +this, we could use the random python module, and the +function random.randrange, which can produce random numbers +within a certain range.

+

Let’s draw 15 randomly placed circles:

+
+

PYTHON +

+
import random
+
+# create the black canvas
+canvas = np.zeros(shape=(600, 800, 3), dtype="uint8")
+
+# draw a blue circle at a random location 15 times
+for i in range(15):
+    rr, cc = ski.draw.disk(center=(
+         random.randrange(600),
+         random.randrange(800)),
+         radius=50,
+         shape=canvas.shape[0:2],
+        )
+    canvas[rr, cc] = (0, 0, 255)
+
+# display the results
+fig, ax = plt.subplots()
+ax.imshow(canvas)
+
+

We could expand this even further to also randomly choose whether to +plot a rectangle, a circle, or a square. Again, we do this with the +random module, now using the function +random.random that returns a random number between 0.0 and +1.0.

+
+

PYTHON +

+
import random
+
+# Draw 15 random shapes (rectangle, circle or line) at random positions
+for i in range(15):
+    # generate a random number between 0.0 and 1.0 and use this to decide if we
+    # want a circle, a line or a sphere
+    x = random.random()
+    if x < 0.33:
+        # draw a blue circle at a random location
+        rr, cc = ski.draw.disk(center=(
+            random.randrange(600),
+            random.randrange(800)),
+            radius=50,
+            shape=canvas.shape[0:2],
+        )
+        color = (0, 0, 255)
+    elif x < 0.66:
+        # draw a green line at a random location
+        rr, cc = ski.draw.line(
+            r0=random.randrange(600),
+            c0=random.randrange(800),
+            r1=random.randrange(600),
+            c1=random.randrange(800),
+        )
+        color = (0, 255, 0)
+    else:
+        # draw a red rectangle at a random location
+        rr, cc = ski.draw.rectangle(
+            start=(random.randrange(600), random.randrange(800)),
+            extent=(50, 50),
+            shape=canvas.shape[0:2],
+        )
+        color = (255, 0, 0)
+
+    canvas[rr, cc] = color
+
+# display the results
+fig, ax = plt.subplots()
+ax.imshow(canvas)
+
+
+
+
+
+

Image modification +

+
+

All that remains is the task of modifying the image using our mask in +such a way that the areas with True pixels in the mask are +not shown in the image any more.

+
+
+ +
+Challenge +
+

How does a mask work? (optional, not included +in timing)

+
+

Now, consider the mask image we created above. The values of the mask +that corresponds to the portion of the image we are interested in are +all False, while the values of the mask that corresponds to +the portion of the image we want to remove are all +True.

+

How do we change the original image using the mask?

+
+
+
+
+
+ +
+
+

When indexing the image using the mask, we access only those pixels +at positions where the mask is True. So, when indexing with +the mask, one can set those values to 0, and effectively remove them +from the image.

+
+
+
+
+

Now we can write a Python program to use a mask to retain only the +portions of our maize roots image that actually contains the seedling +roots. We load the original image and create the mask in the same way as +before:

+
+

PYTHON +

+
# Load the original image
+maize_seedlings = iio.imread(uri="data/maize-seedlings.tif")
+
+# Create the basic mask
+mask = np.ones(shape=maize_seedlings.shape[0:2], dtype="bool")
+
+# Draw a filled rectangle on the mask image
+rr, cc = ski.draw.rectangle(start=(357, 44), end=(740, 720))
+mask[rr, cc] = False
+
+

Then, we use NumPy indexing to remove the portions of the image, +where the mask is True:

+
+

PYTHON +

+
# Apply the mask
+maize_seedlings[mask] = 0
+
+

Then, we display the masked image.

+
+

PYTHON +

+
fig, ax = plt.subplots()
+ax.imshow(maize_seedlings)
+
+

The resulting masked image should look like this:

+
Applied mask
+
+ +
+Challenge +
+

Masking an image of your own (optional, not +included in timing)

+
+

Now, it is your turn to practice. Using your mobile phone, tablet, +webcam, or digital camera, take an image of an object with a simple +overall geometric shape (think rectangular or circular). Copy that image +to your computer, write some code to make a mask, and apply it to select +the part of the image containing your object. For example, here is an +image of a remote control:

+
Remote control image

And, here is the end result of a program masking out everything but +the remote:

+
Remote control masked
+
+
+
+
+
+ +
+
+

Here is a Python program to produce the cropped remote control image +shown above. Of course, your program should be tailored to your +image.

+
+

PYTHON +

+
# Load the image
+remote = iio.imread(uri="data/remote-control.jpg")
+remote = np.array(remote)
+
+# Create the basic mask
+mask = np.ones(shape=remote.shape[0:2], dtype="bool")
+
+# Draw a filled rectangle on the mask image
+rr, cc = ski.draw.rectangle(start=(93, 1107), end=(1821, 1668))
+mask[rr, cc] = False
+
+# Apply the mask
+remote[mask] = 0
+
+# Display the result
+fig, ax = plt.subplots()
+ax.imshow(remote)
+
+
+
+
+
+
+
+ +
+Challenge +
+

Masking a 96-well plate image (30 min)

+
+

Consider this image of a 96-well plate that has been scanned on a +flatbed scanner.

+
+

PYTHON +

+
# Load the image
+wellplate = iio.imread(uri="data/wellplate-01.jpg")
+wellplate = np.array(wellplate)
+
+# Display the image
+fig, ax = plt.subplots()
+ax.imshow(wellplate)
+
+
96-well plate

Suppose that we are interested in the colours of the solutions in +each of the wells. We do not care about the colour of the rest +of the image, i.e., the plastic that makes up the well plate itself.

+

Your task is to write some code that will produce a mask that will +mask out everything except for the wells. To help with this, you should +use the text file data/centers.txt that contains the (cx, +ry) coordinates of the centre of each of the 96 wells in this image. You +may assume that each of the wells has a radius of 16 pixels.

+

Your program should produce output that looks like this:

+
Masked 96-well plate

Hint: You can load data/centers.txt using:

+
+

PYTHON +

+
# load the well coordinates as a NumPy array
+centers = np.loadtxt("data/centers.txt", delimiter=" ")
+
+
+
+
+
+
+ +
+
+
+

PYTHON +

+
# load the well coordinates as a NumPy array
+centers = np.loadtxt("data/centers.txt", delimiter=" ")
+
+# read in original image
+wellplate = iio.imread(uri="data/wellplate-01.jpg")
+wellplate = np.array(wellplate)
+
+# create the mask image
+mask = np.ones(shape=wellplate.shape[0:2], dtype="bool")
+
+# iterate through the well coordinates
+for cx, ry in centers:
+    # draw a circle on the mask at the well center
+    rr, cc = ski.draw.disk(center=(ry, cx), radius=16, shape=wellplate.shape[:2])
+    mask[rr, cc] = False
+
+# apply the mask
+wellplate[mask] = 0
+
+# display the result
+fig, ax = plt.subplots()
+ax.imshow(wellplate)
+
+
+
+
+
+
+
+ +
+Challenge +
+

Masking a 96-well plate image, take two +(optional, not included in timing)

+
+

If you spent some time looking at the contents of the +data/centers.txt file from the previous challenge, you may +have noticed that the centres of each well in the image are very +regular. Assuming that the images are scanned in such a way +that the wells are always in the same place, and that the image is +perfectly oriented (i.e., it does not slant one way or another), we +could produce our well plate mask without having to read in the +coordinates of the centres of each well. Assume that the centre of the +upper left well in the image is at location cx = 91 and ry = 108, and +that there are 70 pixels between each centre in the cx dimension and 72 +pixels between each centre in the ry dimension. Each well still has a +radius of 16 pixels. Write a Python program that produces the same +output image as in the previous challenge, but without having +to read in the centers.txt file. Hint: use nested for +loops.

+
+
+
+
+
+ +
+
+

Here is a Python program that is able to create the masked image +without having to read in the centers.txt file.

+
+

PYTHON +

+
# read in original image
+wellplate = iio.imread(uri="data/wellplate-01.jpg")
+wellplate = np.array(wellplate)
+
+# create the mask image
+mask = np.ones(shape=wellplate.shape[0:2], dtype="bool")
+
+# upper left well coordinates
+cx0 = 91
+ry0 = 108
+
+# spaces between wells
+deltaCX = 70
+deltaRY = 72
+
+cx = cx0
+ry = ry0
+
+# iterate each row and column
+for row in range(12):
+    # reset cx to leftmost well in the row
+    cx = cx0
+    for col in range(8):
+
+        # ... and drawing a circle on the mask
+        rr, cc = ski.draw.disk(center=(ry, cx), radius=16, shape=wellplate.shape[0:2])
+        mask[rr, cc] = False
+        cx += deltaCX
+    # after one complete row, move to next row
+    ry += deltaRY
+
+# apply the mask
+wellplate[mask] = 0
+
+# display the result
+fig, ax = plt.subplots()
+ax.imshow(wellplate)
+
+
+
+
+
+
+
+ +
+Key Points +
+
+
    +
  • We can use the NumPy zeros() function to create a +blank, black image.
  • +
  • We can draw on scikit-image images with functions such as +ski.draw.rectangle(), ski.draw.disk(), +ski.draw.line(), and more.
  • +
  • The drawing functions return indices to pixels that can be set +directly.
  • +
+
+
+
+

Content from Creating Histograms

+
+

Last updated on 2026-03-20 | + + Edit this page

+

Estimated time: 80 minutes

+
+ +
+
+

Overview

+
+
+
+
+

Questions

+
    +
  • How can we create grayscale and colour histograms to understand the +distribution of colour values in an image?
  • +
+
+
+
+
+
+
+

Objectives

+
    +
  • Explain what a histogram is.
  • +
  • Load an image in grayscale format.
  • +
  • Create and display grayscale and colour histograms for entire +images.
  • +
  • Create and display grayscale and colour histograms for certain areas +of images, via masks.
  • +
+
+
+
+
+
+

In this episode, we will learn how to use scikit-image functions to +create and display histograms for images.

+

First, import the packages needed for this episode +

+
+
+

PYTHON +

+
import imageio.v3 as iio
+import ipympl
+import matplotlib.pyplot as plt
+import numpy as np
+import skimage as ski
+
+%matplotlib widget
+
+

Introduction to Histograms +

+
+

As it pertains to images, a histogram is a graphical +representation showing how frequently various colour values occur in the +image. We saw in the Image +Basics episode that we could use a histogram to visualise the +differences in uncompressed and compressed image formats. If your +project involves detecting colour changes between images, histograms +will prove to be very useful, and histograms are also quite handy as a +preparatory step before performing thresholding.

+

Grayscale Histograms +

+
+

We will start with grayscale images, and then move on to colour +images. We will use this image of a plant seedling as an example: Plant seedling

+

Here we load the image in grayscale instead of full colour, and +display it:

+
+

PYTHON +

+
# read the image of a plant seedling as grayscale from the outset
+plant_seedling = iio.imread(uri="data/plant-seedling.jpg", mode="L")
+
+# convert the image to float dtype with a value range from 0 to 1
+plant_seedling = ski.util.img_as_float(plant_seedling)
+
+# display the image
+fig, ax = plt.subplots()
+ax.imshow(plant_seedling, cmap="gray")
+
+
Plant seedling

Again, we use the iio.imread() function to load our +image. Then, we convert the grayscale image of integer dtype, with 0-255 +range, into a floating-point one with 0-1 range, by calling the function +ski.util.img_as_float. We can also calculate histograms for +8 bit images as we will see in the subsequent exercises.

+

We now use the function np.histogram to compute the +histogram of our image which, after all, is a NumPy array:

+
+

PYTHON +

+
# create the histogram
+histogram, bin_edges = np.histogram(plant_seedling, bins=256, range=(0, 1))
+
+

The parameter bins determines the number of “bins” to +use for the histogram. We pass in 256 because we want to +see the pixel count for each of the 256 possible values in the grayscale +image.

+

The parameter range is the range of values each of the +pixels in the image can have. Here, we pass 0 and 1, which is the value +range of our input image after conversion to floating-point.

+

The first output of the np.histogram function is a +one-dimensional NumPy array, with 256 rows and one column, representing +the number of pixels with the intensity value corresponding to the +index. I.e., the first number in the array is the number of pixels found +with intensity value 0, and the final number in the array is the number +of pixels found with intensity value 255. The second output of +np.histogram is an array with the bin edges and one column +and 257 rows (one more than the histogram itself). There are no gaps +between the bins, which means that the end of the first bin, is the +start of the second and so on. For the last bin, the array also has to +contain the stop, so it has one more element, than the histogram.

+

Next, we turn our attention to displaying the histogram, by taking +advantage of the plotting facilities of the Matplotlib library.

+
+

PYTHON +

+
# configure and draw the histogram figure
+fig, ax = plt.subplots()
+ax.set_title("Grayscale Histogram")
+ax.set_xlabel("grayscale value")
+ax.set_ylabel("pixel count")
+ax.set_xlim([0.0, 1.0])  # <- named arguments do not work here
+
+ax.plot(bin_edges[0:-1], histogram)  # <- or here
+
+

We create the plot with plt.subplots(), then label the +figure and the coordinate axes with ax.set_title(), +ax.set_xlabel(), and ax.set_ylabel() +functions. The last step in the preparation of the figure is to set the +limits on the values on the x-axis with the +ax.set_xlim([0.0, 1.0]) function call.

+
+
+ +
+Callout +
+

Variable-length argument lists

+
+

Note that we cannot used named parameters for the +ax.set_xlim() or ax.plot() functions. This is +because these functions are defined to take an arbitrary number of +unnamed arguments. The designers wrote the functions this way +because they are very versatile, and creating named parameters for all +of the possible ways to use them would be complicated.

+
+
+
+

Finally, we create the histogram plot itself with +ax.plot(bin_edges[0:-1], histogram). We use the +left bin edges as x-positions for the histogram values +by indexing the bin_edges array to ignore the last value +(the right edge of the last bin). When we run the +program on this image of a plant seedling, it produces this +histogram:

+
Plant seedling histogram
+
+ +
+Callout +
+

Histograms in Matplotlib

+
+

Matplotlib provides a dedicated function to compute and display +histograms: ax.hist(). We will not use it in this lesson in +order to understand how to calculate histograms in more detail. In +practice, it is a good idea to use this function, because it visualises +histograms more appropriately than ax.plot(). Here, you +could use it by calling +ax.hist(image.flatten(), bins=256, range=(0, 1)) instead of +np.histogram() and ax.plot() +(*.flatten() is a NumPy function that converts our +two-dimensional image into a one-dimensional array).

+
+
+
+
+
+ +
+Challenge +
+

Using a mask for a histogram (15 min)

+
+

Looking at the histogram above, you will notice that there is a large +number of very dark pixels, as indicated in the chart by the spike +around the grayscale value 0.12. That is not so surprising, since the +original image is mostly black background. What if we want to focus more +closely on the leaf of the seedling? That is where a mask enters the +picture!

+

First, hover over the plant seedling image with your mouse to +determine the (x, y) coordinates of a bounding box around the +leaf of the seedling. Then, using techniques from the Drawing and Bitwise Operations +episode, create a mask with a white rectangle covering that bounding +box.

+

After you have created the mask, apply it to the input image before +passing it to the np.histogram function.

+
+
+
+
+
+ +
+
+
+

PYTHON +

+

+# read the image as grayscale from the outset
+plant_seedling = iio.imread(uri="data/plant-seedling.jpg", mode="L")
+
+# convert the image to float dtype with a value range from 0 to 1
+plant_seedling = ski.util.img_as_float(plant_seedling)
+
+# display the image
+fig, ax = plt.subplots()
+ax.imshow(plant_seedling, cmap="gray")
+
+# create mask here, using np.zeros() and ski.draw.rectangle()
+mask = np.zeros(shape=plant_seedling.shape, dtype="bool")
+rr, cc = ski.draw.rectangle(start=(199, 410), end=(384, 485))
+mask[rr, cc] = True
+
+# display the mask
+fig, ax = plt.subplots()
+ax.imshow(mask, cmap="gray")
+
+# mask the image and create the new histogram
+histogram, bin_edges = np.histogram(plant_seedling[mask], bins=256, range=(0.0, 1.0))
+
+# configure and draw the histogram figure
+fig, ax = plt.subplots()
+
+ax.set_title("Grayscale Histogram")
+ax.set_xlabel("grayscale value")
+ax.set_ylabel("pixel count")
+ax.set_xlim([0.0, 1.0])
+ax.plot(bin_edges[0:-1], histogram)
+
+

Your histogram of the masked area should look something like +this:

+
Grayscale histogram of masked area
+
+
+
+
+

Colour Histograms +

+
+

We can also create histograms for full colour images, in addition to +grayscale histograms. We have seen colour histograms before, in the Image Basics episode. A +program to create colour histograms starts in a familiar way:

+
+

PYTHON +

+
# read original image, in full color
+plant_seedling = iio.imread(uri="data/plant-seedling.jpg")
+
+# display the image
+fig, ax = plt.subplots()
+ax.imshow(plant_seedling)
+
+

We read the original image, now in full colour, and display it.

+

Next, we create the histogram, by calling the +np.histogram function three times, once for each of the +channels. We obtain the individual channels, by slicing the image along +the last axis. For example, we can obtain the red colour channel by +calling r_chan = image[:, :, 0].

+
+

PYTHON +

+
# tuple to select colors of each channel line
+colors = ("red", "green", "blue")
+
+# create the histogram plot, with three lines, one for
+# each color
+fig, ax = plt.subplots()
+ax.set_xlim([0, 256])
+for channel_id, color in enumerate(colors):
+    histogram, bin_edges = np.histogram(
+        plant_seedling[:, :, channel_id], bins=256, range=(0, 256)
+    )
+    ax.plot(bin_edges[0:-1], histogram, color=color)
+
+ax.set_title("Color Histogram")
+ax.set_xlabel("Color value")
+ax.set_ylabel("Pixel count")
+
+

We will draw the histogram line for each channel in a different +colour, and so we create a tuple of the colours to use for the three +lines with the

+

colors = ("red", "green", "blue")

+

line of code. Then, we limit the range of the x-axis with the +ax.set_xlim() function call.

+

Next, we use the for control structure to iterate +through the three channels, plotting an appropriately-coloured histogram +line for each. This may be new Python syntax for you, so we will take a +moment to discuss what is happening in the for +statement.

+

The Python built-in enumerate() function takes a list +and returns an iterator of tuples, where the first +element of the tuple is the index and the second element is the element +of the list.

+
+
+ +
+Callout +
+

Iterators, tuples, and +enumerate() +

+
+

In Python, an iterator, or an iterable object, is +something that can be iterated over with the for control +structure. A tuple is a sequence of objects, just like a list. +However, a tuple cannot be changed, and a tuple is indicated by +parentheses instead of square brackets. The enumerate() +function takes an iterable object, and returns an iterator of tuples +consisting of the 0-based index and the corresponding object.

+

For example, consider this small Python program:

+
+

PYTHON +

+
list = ("a", "b", "c", "d", "e")
+
+for x in enumerate(list):
+    print(x)
+
+

Executing this program would produce the following output:

+
+

OUTPUT +

+
(0, 'a')
+(1, 'b')
+(2, 'c')
+(3, 'd')
+(4, 'e')
+
+
+
+
+

In our colour histogram program, we are using a tuple, +(channel_id, color), as the for variable. The +first time through the loop, the channel_id variable takes +the value 0, referring to the position of the red colour +channel, and the color variable contains the string +"red". The second time through the loop the values are the +green channels index 1 and "green", and the +third time they are the blue channel index 2 and +"blue".

+

Inside the for loop, our code looks much like it did for +the grayscale example. We calculate the histogram for the current +channel with the

+

histogram, bin_edges = np.histogram(image[:, :, channel_id], bins=256, range=(0, 256))

+

function call, and then add a histogram line of the correct colour to +the plot with the

+

ax.plot(bin_edges[0:-1], histogram, color=color)

+

function call. Note the use of our loop variables, +channel_id and color.

+

Finally we label our axes and display the histogram, shown here:

+
Colour histogram
+
+ +
+Challenge +
+

Colour histogram with a mask (25 min)

+
+

We can also apply a mask to the images we apply the colour histogram +process to, in the same way we did for grayscale histograms. Consider +this image of a well plate, where various chemical sensors have been +applied to water and various concentrations of hydrochloric acid and +sodium hydroxide:

+
+

PYTHON +

+
# read the image
+wellplate = iio.imread(uri="data/wellplate-02.tif")
+
+# display the image
+fig, ax = plt.subplots()
+ax.imshow(wellplate)
+
+
Well plate image

Suppose we are interested in the colour histogram of one of the +sensors in the well plate image, specifically, the seventh well from the +left in the topmost row, which shows Erythrosin B reacting with +water.

+

Hover over the image with your mouse to find the centre of that well +and the radius (in pixels) of the well. Then create a circular mask to +select only the desired well. Then, use that mask to apply the colour +histogram operation to that well.

+

Your masked image should look like this:

+
Masked well plate

And, the program should produce a colour histogram that looks like +this:

+
Well plate histogram
+
+
+
+
+
+ +
+
+
+

PYTHON +

+
# create a circular mask to select the 7th well in the first row
+mask = np.zeros(shape=wellplate.shape[0:2], dtype="bool")
+circle = ski.draw.disk(center=(240, 1053), radius=49, shape=wellplate.shape[0:2])
+mask[circle] = 1
+
+# just for display:
+# make a copy of the image, call it masked_image, and
+# zero values where mask is False
+masked_img = np.array(wellplate)
+masked_img[~mask] = 0
+
+# create a new figure and display masked_img, to verify the
+# validity of your mask
+fig, ax = plt.subplots()
+ax.imshow(masked_img)
+
+# list to select colors of each channel line
+colors = ("red", "green", "blue")
+
+# create the histogram plot, with three lines, one for
+# each color
+fig, ax = plt.subplots()
+ax.set_xlim([0, 256])
+for (channel_id, color) in enumerate(colors):
+    # use your circular mask to apply the histogram
+    # operation to the 7th well of the first row
+    histogram, bin_edges = np.histogram(
+        wellplate[:, :, channel_id][mask], bins=256, range=(0, 256)
+    )
+
+    ax.plot(histogram, color=color)
+
+ax.set_xlabel("color value")
+ax.set_ylabel("pixel count")
+
+
+
+
+
+
+
+ +
+Key Points +
+
+
    +
  • In many cases, we can load images in grayscale by passing the +mode="L" argument to the iio.imread() +function.
  • +
  • We can create histograms of images with the +np.histogram function.
  • +
  • We can display histograms using ax.plot() with the +bin_edges and histogram values returned by +np.histogram().
  • +
  • The plot can be customised using ax.set_xlabel(), +ax.set_ylabel(), ax.set_xlim(), +ax.set_ylim(), and ax.set_title().
  • +
  • We can separate the colour channels of an RGB image using slicing +operations and create histograms for each colour channel +separately.
  • +
+
+
+
+

Content from Blurring Images

+
+

Last updated on 2026-03-20 | + + Edit this page

+

Estimated time: 60 minutes

+
+ +
+
+

Overview

+
+
+
+
+

Questions

+
    +
  • How can we apply a low-pass blurring filter to an image?
  • +
+
+
+
+
+
+
+

Objectives

+
    +
  • Explain why applying a low-pass blurring filter to an image is +beneficial.
  • +
  • Apply a Gaussian blur filter to an image using scikit-image.
  • +
+
+
+
+
+
+

In this episode, we will learn how to use scikit-image functions to +blur images.

+

When processing an image, we are often interested in identifying +objects represented within it so that we can perform some further +analysis of these objects, e.g., by counting them, measuring their +sizes, etc. An important concept associated with the identification of +objects in an image is that of edges: the lines that represent +a transition from one group of similar pixels in the image to another +different group. One example of an edge is the pixels that represent the +boundaries of an object in an image, where the background of the image +ends and the object begins.

+

When we blur an image, we make the colour transition from one side of +an edge in the image to another smooth rather than sudden. The effect is +to average out rapid changes in pixel intensity. Blurring is a very +common operation we need to perform before other tasks such as thresholding. There are several +different blurring functions in the ski.filters module, so +we will focus on just one here, the Gaussian blur.

+
+
+ +
+Callout +
+

Filters

+
+

In the day-to-day, macroscopic world, we have physical filters which +separate out objects by size. A filter with small holes allows only +small objects through, leaving larger objects behind. This is a good +analogy for image filters. A high-pass filter will retain the smaller +details in an image, filtering out the larger ones. A low-pass filter +retains the larger features, analogous to what’s left behind by a +physical filter mesh. High- and low-pass, here, refer +to high and low spatial frequencies in the image. Details +associated with high spatial frequencies are small, a lot of these +features would fit across an image. Features associated with low spatial +frequencies are large - maybe a couple of big features per image.

+
+
+
+
+
+ +
+Callout +
+

Blurring

+
+

To blur is to make something less clear or distinct. This could be +interpreted quite broadly in the context of image analysis - anything +that reduces or distorts the detail of an image might apply. Applying a +low-pass filter, which removes detail occurring at high spatial +frequencies, is perceived as a blurring effect. A Gaussian blur is a +filter that makes use of a Gaussian kernel.

+
+
+
+
+
+ +
+Callout +
+

Kernels

+
+

A kernel can be used to implement a filter on an image. A kernel, in +this context, is a small matrix which is combined with the image using a +mathematical technique: convolution. Different sizes, shapes +and contents of kernel produce different effects. The kernel can be +thought of as a little image in itself, and will favour features of +similar size and shape in the main image. On convolution with an image, +a big, blobby kernel will retain big, blobby, low spatial frequency +features.

+
+
+
+

Gaussian blur +

+
+

Consider this image of a cat, in particular the area of the image +outlined by the white square.

+
Cat image

Now, zoom in on the area of the cat’s eye, as shown in the left-hand +image below. When we apply a filter, we consider each pixel in the +image, one at a time. In this example, the pixel we are currently +working on is highlighted in red, as shown in the right-hand image.

+
Cat eye pixels

When we apply a filter, we consider rectangular groups of pixels +surrounding each pixel in the image, in turn. The kernel is +another group of pixels (a separate matrix / small image), of the same +dimensions as the rectangular group of pixels in the image, that moves +along with the pixel being worked on by the filter. The width and height +of the kernel must be an odd number, so that the pixel being worked on +is always in its centre. In the example shown above, the kernel is +square, with a dimension of seven pixels.

+

To apply the kernel to the current pixel, an average of the colour +values of the pixels surrounding it is calculated, weighted by the +values in the kernel. In a Gaussian blur, the pixels nearest the centre +of the kernel are given more weight than those far away from the centre. +The rate at which this weight diminishes is determined by a Gaussian +function, hence the name Gaussian blur.

+

A Gaussian function maps random variables into a normal distribution +or “Bell Curve”. Gaussian function

+ +

The shape of the function is described by a mean value μ, and a +variance value σ². The mean determines the central point of the bell +curve on the X axis, and the variance describes the spread of the +curve.

+

In fact, when using Gaussian functions in Gaussian blurring, we use a +2D Gaussian function to account for X and Y dimensions, but the same +rules apply. The mean μ is always 0, and represents the middle of the 2D +kernel. Increasing values of σ² in either dimension increases the amount +of blurring in that dimension.

+
2D Gaussian function
+

The averaging is done on a channel-by-channel basis, and the average +channel values become the new value for the pixel in the filtered image. +Larger kernels have more values factored into the average, and this +implies that a larger kernel will blur the image more than a smaller +kernel.

+

To get an idea of how this works, consider this plot of the +two-dimensional Gaussian function:

+
2D Gaussian function

Imagine that plot laid over the kernel for the Gaussian blur filter. +The height of the plot corresponds to the weight given to the underlying +pixel in the kernel. I.e., the pixels close to the centre become more +important to the filtered pixel colour than the pixels close to the +outer limits of the kernel. The shape of the Gaussian function is +controlled via its standard deviation, or sigma. A large sigma value +results in a flatter shape, while a smaller sigma value results in a +more pronounced peak. The mathematics involved in the Gaussian blur +filter are not quite that simple, but this explanation gives you the +basic idea.

+

To illustrate the blurring process, consider the blue channel colour +values from the seven-by-seven region of the cat image above:

+
Image corner pixels

The filter is going to determine the new blue channel value for the +centre pixel – the one that currently has the value 86. The filter +calculates a weighted average of all the blue channel values in the +kernel giving higher weight to the pixels near the centre of the +kernel.

+
Image multiplication

This weighted average, the sum of the multiplications, becomes the +new value for the centre pixel (3, 3). The same process would be used to +determine the green and red channel values, and then the kernel would be +moved over to apply the filter to the next pixel in the image.

+
+
+ +
+
+

Take care to avoid mixing up the term “edge” to describe the edges of +objects within an image and the outer boundaries of the images +themselves. Lack of a clear distinction here may be confusing for +learners.

+
+
+
+
+
+
+ +
+Callout +
+

Image edges

+
+

Something different needs to happen for pixels near the outer limits +of the image, since the kernel for the filter may be partially off the +image. For example, what happens when the filter is applied to the +upper-left pixel of the image? Here are the blue channel pixel values +for the upper-left pixel of the cat image, again assuming a +seven-by-seven kernel:

+
+

OUTPUT +

+
  x   x   x   x   x   x   x
+  x   x   x   x   x   x   x
+  x   x   x   x   x   x   x
+  x   x   x   4   5   9   2
+  x   x   x   5   3   6   7
+  x   x   x   6   5   7   8
+  x   x   x   5   4   5   3
+
+

The upper-left pixel is the one with value 4. Since the pixel is at +the upper-left corner, there are no pixels underneath much of the +kernel; here, this is represented by x’s. So, what does the filter do in +that situation?

+

The default mode is to fill in the nearest pixel value from +the image. For each of the missing x’s the image value closest to the x +is used. If we fill in a few of the missing pixels, you will see how +this works:

+
+

OUTPUT +

+
  x   x   x   4   x   x   x
+  x   x   x   4   x   x   x
+  x   x   x   4   x   x   x
+  4   4   4   4   5   9   2
+  x   x   x   5   3   6   7
+  x   x   x   6   5   7   8
+  x   x   x   5   4   5   3
+
+

Another strategy to fill those missing values is to reflect +the pixels that are in the image to fill in for the pixels that are +missing from the kernel.

+
+

OUTPUT +

+
  x   x   x   5   x   x   x
+  x   x   x   6   x   x   x
+  x   x   x   5   x   x   x
+  2   9   5   4   5   9   2
+  x   x   x   5   3   6   7
+  x   x   x   6   5   7   8
+  x   x   x   5   4   5   3
+
+

A similar process would be used to fill in all of the other missing +pixels from the kernel. Other border modes are available; you +can learn more about them in the scikit-image +documentation.

+
+
+
+

Let’s consider a very simple image to see blurring in action. The +animation below shows how the blur kernel (large red square) moves along +the image on the left in order to calculate the corresponding values for +the blurred image (yellow central square) on the right. In this simple +case, the original image is single-channel, but blurring would work +likewise on a multi-channel image.

+
Blur demo animation

scikit-image has built-in functions to perform blurring for us, so we +do not have to perform all of these mathematical operations ourselves. +Let’s work through an example of blurring an image with the scikit-image +Gaussian blur function.

+

First, import the packages needed for this episode:

+
+

PYTHON +

+
import imageio.v3 as iio
+import ipympl
+import matplotlib.pyplot as plt
+import skimage as ski
+
+%matplotlib widget
+
+

Then, we load the image, and display it:

+
+

PYTHON +

+
image = iio.imread(uri="data/gaussian-original.png")
+
+# display the image
+fig, ax = plt.subplots()
+ax.imshow(image)
+
+
Original image

Next, we apply the gaussian blur:

+
+

PYTHON +

+
sigma = 3.0
+
+# apply Gaussian blur, creating a new image
+blurred = ski.filters.gaussian(
+    image, sigma=(sigma, sigma), truncate=3.5, channel_axis=-1)
+
+

The first two arguments to ski.filters.gaussian() are +the image to blur, image, and a tuple defining the sigma to +use in ry- and cx-direction, (sigma, sigma). The third +parameter truncate is meant to pass the radius of the +kernel in number of sigmas. A Gaussian function is defined from +-infinity to +infinity, but our kernel (which must have a finite, +smaller size) can only approximate the real function. Therefore, we must +choose a certain distance from the centre of the function where we stop +this approximation, and set the final size of our kernel. In the above +example, we set truncate to 3.5, which means the kernel +size will be 2 * sigma * 3.5. For example, for a sigma of +1.0 the resulting kernel size would be 7, while for a sigma +of 2.0 the kernel size would be 14. The default value for +truncate in scikit-image is 4.0.

+

The last argument we passed to ski.filters.gaussian() is +used to specify the dimension which contains the (colour) channels. +Here, it is the last dimension; recall that, in Python, the +-1 index refers to the last position. In this case, the +last dimension is the third dimension (index 2), since our +image has three dimensions:

+
+

PYTHON +

+
print(image.ndim)
+
+
+

OUTPUT +

+
3
+
+

Finally, we display the blurred image:

+
+

PYTHON +

+
# display blurred image
+fig, ax = plt.subplots()
+ax.imshow(blurred)
+
+
Blurred image

Visualising Blurring +

+
+

Somebody said once “an image is worth a thousand words”. What is +actually happening to the image pixels when we apply blurring may be +difficult to grasp. Let’s now visualise the effects of blurring from a +different perspective.

+

Let’s use the petri-dish image from previous episodes:

+
Bacteria colony
Graysacle version of the Petri dish image
+

What we want to see here is the pixel intensities from a lateral +perspective: we want to see the profile of intensities. For instance, +let’s look for the intensities of the pixels along the horizontal line +at Y=150:

+
+

PYTHON +

+
# read colonies color image and convert to grayscale
+image = iio.imread('data/colonies-01.tif')
+image_gray = ski.color.rgb2gray(image)
+
+# define the pixels for which we want to view the intensity (profile)
+xmin, xmax = (0, image_gray.shape[1])
+Y = ymin = ymax = 150
+
+# view the image indicating the profile pixels position
+fig, ax = plt.subplots()
+ax.imshow(image_gray, cmap='gray')
+ax.plot([xmin, xmax], [ymin, ymax], color='red')
+
+
Bacteria colony image with selected pixels marker
Grayscale Petri dish image marking selected +pixels for profiling
+

The intensity of those pixels we can see with a simple line plot:

+
+

PYTHON +

+
# select the vector of pixels along "Y"
+image_gray_pixels_slice = image_gray[Y, :]
+
+# guarantee the intensity values are in the [0:255] range (unsigned integers)
+image_gray_pixels_slice = ski.img_as_ubyte(image_gray_pixels_slice)
+
+fig, ax = plt.subplots()
+ax.plot(image_gray_pixels_slice, color='red')
+ax.set_ylim(255, 0)
+ax.set_ylabel('L')
+ax.set_xlabel('X')
+
+
Pixel intensities profile in original image
Intensities profile line plot of pixels along +Y=150 in original image
+

And now, how does the same set of pixels look in the corresponding +blurred image:

+
+

PYTHON +

+
# first, create a blurred version of (grayscale) image
+image_blur = ski.filters.gaussian(image_gray, sigma=3)
+
+# like before, plot the pixels profile along "Y"
+image_blur_pixels_slice = image_blur[Y, :]
+image_blur_pixels_slice = ski.img_as_ubyte(image_blur_pixels_slice)
+
+fig, ax = plt.subplots()
+ax.plot(image_blur_pixels_slice, 'red')
+ax.set_ylim(255, 0)
+ax.set_ylabel('L')
+ax.set_xlabel('X')
+
+
Pixel intensities profile in blurred image
Intensities profile of pixels along Y=150 in +blurred image
+

And that is why blurring is also called smoothing. +This is how low-pass filters affect neighbouring pixels.

+

Now that we have seen the effects of blurring an image from two +different perspectives, front and lateral, let’s take yet another look +using a 3D visualisation.

+
+
+ +
+Callout +
+

3D Plots with matplotlib

+
+

The code to generate these 3D plots is outside the scope of this +lesson but can be viewed by following the links in the captions.

+
+
+
+
3D surface plot showing pixel intensities across the whole example Petri dish image before blurring
A 3D plot of pixel intensities across the whole +Petri dish image before blurring. Explore +how this plot was created with matplotlib. Image credit: Carlos H Brandt.
+
3D surface plot illustrating the smoothing effect on pixel intensities across the whole example Petri dish image after blurring
A 3D plot of pixel intensities after Gaussian +blurring of the Petri dish image. Note the ‘smoothing’ effect on the +pixel intensities of the colonies in the image, and the ‘flattening’ of +the background noise at relatively low pixel intensities throughout the +image. Explore +how this plot was created with matplotlib. Image credit: Carlos H Brandt.
+
+
+ +
+Challenge +
+

Experimenting with sigma values (10 min)

+
+

The size and shape of the kernel used to blur an image can have a +significant effect on the result of the blurring and any downstream +analysis carried out on the blurred image. The next two exercises ask +you to experiment with the sigma values of the kernel, which is a good +way to develop your understanding of how the choice of kernel can +influence the result of blurring.

+

First, try running the code above with a range of smaller and larger +sigma values. Generally speaking, what effect does the sigma value have +on the blurred image?

+
+
+
+
+
+ +
+
+

Generally speaking, the larger the sigma value, the more blurry the +result. A larger sigma will tend to get rid of more noise in the image, +which will help for other operations we will cover soon, such as +thresholding. However, a larger sigma also tends to eliminate some of +the detail from the image. So, we must strike a balance with the sigma +value used for blur filters.

+
+
+
+
+
+
+ +
+Challenge +
+

Experimenting with kernel shape (10 min - +optional, not included in timing)

+
+

Now, what is the effect of applying an asymmetric kernel to blurring +an image? Try running the code above with different sigmas in the ry and +cx direction. For example, a sigma of 1.0 in the ry direction, and 6.0 +in the cx direction.

+
+
+
+
+
+ +
+
+
+

PYTHON +

+
# apply Gaussian blur, with a sigma of 1.0 in the ry direction, and 6.0 in the cx direction
+blurred = ski.filters.gaussian(
+    image, sigma=(1.0, 6.0), truncate=3.5, channel_axis=-1
+)
+
+# display blurred image
+fig, ax = plt.subplots()
+ax.imshow(blurred)
+
+
Rectangular kernel blurred image

These unequal sigma values produce a kernel that is rectangular +instead of square. The result is an image that is much more blurred in +the X direction than in the Y direction. For most use cases, a uniform +blurring effect is desirable and this kind of asymmetric blurring should +be avoided. However, it can be helpful in specific circumstances, e.g., +when noise is present in your image in a particular pattern or +orientation, such as vertical lines, or when you want to remove +uniform noise without blurring edges present in the image in a +particular orientation.

+
+
+
+
+

Other methods of blurring +

+
+

The Gaussian blur is a way to apply a low-pass filter in +scikit-image. It is often used to remove Gaussian (i.e., random) noise +in an image. For other kinds of noise, e.g., “salt and pepper”, a median +filter is typically used. See the +skimage.filters documentation for a list of available +filters.

+
+
+ +
+Key Points +
+
+
    +
  • Applying a low-pass blurring filter smooths edges and removes noise +from an image.
  • +
  • Blurring is often used as a first step before we perform +thresholding or edge detection.
  • +
  • The Gaussian blur can be applied to an image with the +ski.filters.gaussian() function.
  • +
  • Larger sigma values may remove more noise, but they will also remove +detail from an image.
  • +
+
+
+
+

Content from Thresholding

+
+

Last updated on 2026-04-28 | + + Edit this page

+

Estimated time: 110 minutes

+
+ +
+
+

Overview

+
+
+
+
+

Questions

+
    +
  • How can we use thresholding to produce a binary image?
  • +
+
+
+
+
+
+
+

Objectives

+
    +
  • Explain what thresholding is and how it can be used.
  • +
  • Use histograms to determine appropriate threshold values to use for +the thresholding process.
  • +
  • Apply simple, fixed-level binary thresholding to an image.
  • +
  • Explain the difference between using the operator > +or the operator < to threshold an image represented by a +NumPy array.
  • +
  • Describe the shape of a binary image produced by thresholding via +> or <.
  • +
  • Explain when Otsu’s method for automatic thresholding is +appropriate.
  • +
  • Apply automatic thresholding to an image using Otsu’s method.
  • +
  • Use the np.count_nonzero() function to count the number +of non-zero pixels in an image.
  • +
+
+
+
+
+
+

In this episode, we will learn how to use scikit-image functions to +apply thresholding to an image. Thresholding is a type of image +segmentation, where an image is split into different regions, or +segments. These segments can then be analyzed separately.

+

In thresholding, we convert an image from colour or grayscale into a +binary image, i.e., one that is simply black and white. Most +frequently, we use thresholding as a way to select areas of interest of +an image, while ignoring the parts we are not concerned with. We have +already done some simple thresholding, in the “Manipulating pixels” +section of the Working with +scikit-image episode. In that case, we used a simple NumPy +array manipulation to separate the pixels belonging to the root system +of a plant from the black background. In this episode, we will learn how +to use scikit-image functions to perform thresholding. Then, we will use +the masks returned by these functions to select the parts of an image we +are interested in.

+

First, import the packages needed for this episode +

+
+
+

PYTHON +

+
import glob
+
+import imageio.v3 as iio
+import ipympl
+import matplotlib.pyplot as plt
+import numpy as np
+import skimage as ski
+
+%matplotlib widget
+
+

Simple thresholding +

+
+

Consider the image data/shapes-01.jpg with a series of +crudely cut shapes set against a white background.

+
+

PYTHON +

+
# load the image
+shapes01 = iio.imread(uri="data/shapes-01.jpg")
+
+fig, ax = plt.subplots()
+ax.imshow(shapes01)
+
+
Image with geometric shapes on white background

Now suppose we want to select only the shapes from the image. In +other words, we want to leave the pixels belonging to the shapes “on,” +while turning the rest of the pixels “off,” by setting their colour +channel values to zeros. The scikit-image library has several different +methods of thresholding. We will start with the simplest version, which +involves an important step of human input. Specifically, in this simple, +fixed-level thresholding, we have to provide a threshold value +t.

+

The process works like this. First, we will load the original image, +convert it to grayscale, and de-noise it as in the Blurring Images episode.

+
+

PYTHON +

+
# convert the image to grayscale
+gray_shapes = ski.color.rgb2gray(shapes01)
+
+# blur the image to denoise
+blurred_shapes = ski.filters.gaussian(gray_shapes, sigma=1.0)
+
+fig, ax = plt.subplots()
+ax.imshow(blurred_shapes, cmap="gray")
+
+
Grayscale image of the geometric shapes

Next, we would like to apply the threshold t such that +pixels with grayscale values on one side of t will be +turned “on”, while pixels with grayscale values on the other side will +be turned “off”. How might we do that? Remember that grayscale images +contain pixel values in the range from 0 to 1, so we are looking for a +threshold t in the closed range [0.0, 1.0]. We see in the +image that the geometric shapes are “darker” than the white background +but there is also some light gray noise on the background. One way to +determine a “good” value for t is to look at the grayscale +histogram of the image and try to identify what grayscale ranges +correspond to the shapes in the image or the background.

+

The histogram for the shapes image shown above can be produced as in +the Creating Histograms +episode.

+
+

PYTHON +

+
# create a histogram of the blurred grayscale image
+histogram, bin_edges = np.histogram(blurred_shapes, bins=256, range=(0.0, 1.0))
+
+fig, ax = plt.subplots()
+ax.plot(bin_edges[0:-1], histogram)
+ax.set_title("Grayscale Histogram")
+ax.set_xlabel("grayscale value")
+ax.set_ylabel("pixels")
+ax.set_xlim(0, 1.0)
+
+
Grayscale histogram of the geometric shapes image

Since the image has a white background, most of the pixels in the +image are white. This corresponds nicely to what we see in the +histogram: there is a peak near the value of 1.0. If we want to select +the shapes and not the background, we want to turn off the white +background pixels, while leaving the pixels for the shapes turned on. +So, we should choose a value of t somewhere before the +large peak and turn pixels above that value “off”. Let us choose +t=0.8.

+

To apply the threshold t, we can use the NumPy +comparison operators to create a mask. Here, we want to turn “on” all +pixels which have values smaller than the threshold, so we use the less +operator < to compare the blurred_image to +the threshold t. The operator returns a mask, that we +capture in the variable binary_mask. It has only one +channel, and each of its values is either 0 or 1. The binary mask +created by the thresholding operation can be shown with +ax.imshow, where the False entries are shown +as black pixels (0-valued) and the True entries are shown +as white pixels (1-valued).

+
+

PYTHON +

+
# create a mask based on the threshold
+t = 0.8
+binary_mask = blurred_shapes < t
+
+fig, ax = plt.subplots()
+ax.imshow(binary_mask, cmap="gray")
+
+
Binary mask of the geometric shapes created by thresholding

You can see that the areas where the shapes were in the original area +are now white, while the rest of the mask image is black.

+
+
+ +
+Callout +
+

What makes a good threshold?

+
+

As is often the case, the answer to this question is “it depends”. In +the example above, we could have just switched off all the white +background pixels by choosing t=1.0, but this would leave +us with some background noise in the mask image. On the other hand, if +we choose too low a value for the threshold, we could lose some of the +shapes that are too bright. You can experiment with the threshold by +re-running the above code lines with different values for +t. In practice, it is a matter of domain knowledge and +experience to interpret the peaks in the histogram so to determine an +appropriate threshold. The process often involves trial and error, which +is a drawback of the simple thresholding method. Below we will introduce +automatic thresholding, which uses a quantitative, mathematical +definition for a good threshold that allows us to determine the value of +t automatically. It is worth noting that the principle for +simple and automatic thresholding can also be used for images with pixel +ranges other than [0.0, 1.0]. For example, we could perform thresholding +on pixel intensity values in the range [0, 255] as we have already seen +in the Working with +scikit-image episode.

+
+
+
+

We can now apply the binary_mask to the original +coloured image as we have learned in the +Drawing and Bitwise Operations episode. What we are left +with is only the coloured shapes from the original.

+
+

PYTHON +

+
# use the binary_mask to select the "interesting" part of the image
+selection = shapes01.copy()
+selection[~binary_mask] = 0
+
+fig, ax = plt.subplots()
+ax.imshow(selection)
+
+
Selected shapes after applying binary mask
+
+ +
+Challenge +
+

More practice with simple thresholding (15 +min)

+
+

Now, it is your turn to practice. Suppose we want to use simple +thresholding to select only the coloured shapes (in this particular case +we consider grayish to be a colour, too) from the image +data/shapes-02.jpg:

+
Another image with geometric shapes on white background

First, plot the grayscale histogram as in the Creating Histogram episode and +examine the distribution of grayscale values in the image. What do you +think would be a good value for the threshold t?

+
+
+
+
+
+ +
+
+

The histogram for the data/shapes-02.jpg image can be +shown with

+
+

PYTHON +

+
shapes = iio.imread(uri="data/shapes-02.jpg")
+gray_shapes = ski.color.rgb2gray(shapes)
+histogram, bin_edges = np.histogram(gray_shapes, bins=256, range=(0.0, 1.0))
+
+fig, ax = plt.subplots()
+ax.plot(bin_edges[0:-1], histogram)
+ax.set_title("Graylevel histogram")
+ax.set_xlabel("gray value")
+ax.set_ylabel("pixel count")
+ax.set_xlim(0, 1.0)
+
+
Grayscale histogram of the second geometric shapes image

We can see a large spike around 0.3, and a smaller spike around 0.7. +The spike near 0.3 represents the darker background, so it seems like a +value close to t=0.5 would be a good choice.

+
+
+
+
+
+
+ +
+Challenge +
+

More practice with simple thresholding (15 +min) (continued) +

+
+

Next, create a mask to turn the pixels above the threshold +t on and pixels below the threshold t off. +Note that unlike the image with a white background we used above, here +the peak for the background colour is at a lower gray level than the +shapes. Therefore, change the comparison operator less < +to greater > to create the appropriate mask. Then apply +the mask to the image and view the thresholded image. If everything +works as it should, your output should show only the coloured shapes on +a black background.

+
+
+
+
+
+ +
+
+

Here are the commands to create and view the binary mask

+
+

PYTHON +

+
t = 0.5
+binary_mask = gray_shapes > t
+
+fig, ax = plt.subplots()
+ax.imshow(binary_mask, cmap="gray")
+
+
Binary mask created by thresholding the second geometric shapes image

And here are the commands to apply the mask and view the thresholded +image

+
+

PYTHON +

+
shapes02 = iio.imread(uri="data/shapes-02.jpg")
+selection = shapes02.copy()
+selection[~binary_mask] = 0
+
+fig, ax = plt.subplots()
+ax.imshow(selection)
+
+
Selected shapes after applying binary mask to the second geometric shapes image
+
+
+
+
+

Automatic thresholding +

+
+

The downside of the simple thresholding technique is that we have to +make an educated guess about the threshold t by inspecting +the histogram. There are also automatic thresholding methods +that can determine the threshold automatically for us. One such method +is Otsu’s +method. It is particularly useful for situations where the +grayscale histogram of an image has two peaks that correspond to +background and objects of interest.

+
+
+ +
+Callout +
+

Denoising an image before thresholding

+
+

In practice, it is often necessary to denoise the image before +thresholding, which can be done with one of the methods from the Blurring Images episode.

+
+
+
+

Consider the image data/maize-root-cluster.jpg of a +maize root system which we have seen before in the Working with scikit-image +episode.

+
+

PYTHON +

+
maize_roots = iio.imread(uri="data/maize-root-cluster.jpg")
+
+fig, ax = plt.subplots()
+ax.imshow(maize_roots)
+
+
Image of a maize root

We use Gaussian blur with a sigma of 1.0 to denoise the root image. +Let us look at the grayscale histogram of the denoised image.

+
+

PYTHON +

+
# convert the image to grayscale
+gray_image = ski.color.rgb2gray(maize_roots)
+
+# blur the image to denoise
+blurred_image = ski.filters.gaussian(gray_image, sigma=1.0)
+
+# show the histogram of the blurred image
+histogram, bin_edges = np.histogram(blurred_image, bins=256, range=(0.0, 1.0))
+fig, ax = plt.subplots()
+ax.plot(bin_edges[0:-1], histogram)
+ax.set_title("Graylevel histogram")
+ax.set_xlabel("gray value")
+ax.set_ylabel("pixel count")
+ax.set_xlim(0, 1.0)
+
+
Grayscale histogram of the maize root image

The histogram has a significant peak around 0.2 and then a broader +“hill” around 0.6 followed by a smaller peak near 1.0. Looking at the +grayscale image, we can identify the peak at 0.2 with the background and +the broader peak with the foreground. Thus, this image is a good +candidate for thresholding with Otsu’s method. The mathematical details +of how this works are complicated (see the +scikit-image documentation if you are interested), but the outcome +is that Otsu’s method finds a threshold value between the two peaks of a +grayscale histogram which might correspond well to the foreground and +background depending on the data and application.

+
+
+ +
+
+

The histogram of the maize root image may prompt questions from +learners about the interpretation of the peaks and the broader region +around 0.6. The focus here is on the separation of background and +foreground pixel values. We note that Otsu’s method does not work well +for the image with the shapes used earlier in this episode, as the +foreground pixel values are more distributed. These examples could be +augmented with a discussion of unimodal, bimodal, and multimodal +histograms. While these points can lead to fruitful considerations, the +text in this episode attempts to reduce cognitive load and deliberately +simplifies the discussion.

+
+
+
+
+

The ski.filters.threshold_otsu() function can be used to +determine the threshold automatically via Otsu’s method. Then NumPy +comparison operators can be used to apply it as before. Here are the +Python commands to determine the threshold t with Otsu’s +method.

+
+

PYTHON +

+
# perform automatic thresholding
+t = ski.filters.threshold_otsu(blurred_image)
+print("Found automatic threshold t = {}.".format(t))
+
+
+

OUTPUT +

+
Found automatic threshold t = 0.4116003928683858.
+
+

For this root image and a Gaussian blur with the chosen sigma of 1.0, +the computed threshold value is 0.42. No we can create a binary mask +with the comparison operator >. As we have seen before, +pixels above the threshold value will be turned on, those below the +threshold will be turned off.

+
+

PYTHON +

+
# create a binary mask with the threshold found by Otsu's method
+binary_mask = blurred_image > t
+
+fig, ax = plt.subplots()
+ax.imshow(binary_mask, cmap="gray")
+
+
Binary mask of the maize root system

Finally, we use the mask to select the foreground:

+
+

PYTHON +

+
# apply the binary mask to select the foreground
+selection = maize_roots.copy()
+selection[~binary_mask] = 0
+
+fig, ax = plt.subplots()
+ax.imshow(selection)
+
+
Masked selection of the maize root system

Application: measuring root mass +

+
+

Let us now turn to an application where we can apply thresholding and +other techniques we have learned to this point. Consider these four +maize root system images, which you can find in the files +data/trial-016.jpg, data/trial-020.jpg, +data/trial-216.jpg, and +data/trial-293.jpg.

+
Four images of maize roots

Suppose we are interested in the amount of plant material in each +image, and in particular how that amount changes from image to image. +Perhaps the images represent the growth of the plant over time, or +perhaps the images show four different maize varieties at the same phase +of their growth. The question we would like to answer is, “how much root +mass is in each image?”

+

We will first construct a Python program to measure this value for a +single image. Our strategy will be this:

+
    +
  1. Read the image, converting it to grayscale as it is read. For this +application we do not need the colour image.
  2. +
  3. Blur the image.
  4. +
  5. Use Otsu’s method of thresholding to create a binary image, where +the pixels that were part of the maize plant are white, and everything +else is black.
  6. +
  7. Save the binary image so it can be examined later.
  8. +
  9. Count the white pixels in the binary image, and divide by the number +of pixels in the image. This ratio will be a measure of the root mass of +the plant in the image.
  10. +
  11. Output the name of the image processed and the root mass ratio.
  12. +
+

Our intent is to perform these steps and produce the numeric result - +a measure of the root mass in the image - without human intervention. +Implementing the steps within a Python function will enable us to call +this function for different images.

+

Here is a Python function that implements this root-mass-measuring +strategy. Since the function is intended to produce numeric output +without human interaction, it does not display any of the images. Almost +all of the commands should be familiar, and in fact, it may seem simpler +than the code we have worked on thus far, because we are not displaying +any of the images.

+
+

PYTHON +

+
def measure_root_mass(filename, sigma=1.0):
+
+    # read the original image, converting to grayscale on the fly
+    image = iio.imread(uri=filename, mode="L")
+
+    # blur before thresholding
+    blurred_image = ski.filters.gaussian(image, sigma=sigma)
+
+    # perform automatic thresholding to produce a binary image
+    t = ski.filters.threshold_otsu(blurred_image)
+    binary_mask = blurred_image > t
+
+    # determine root mass ratio
+    root_pixels = np.count_nonzero(binary_mask)
+    density = root_pixels / binary_mask.size
+
+    return density
+
+

The function begins with reading the original image from the file +filename. We use iio.imread() with the +optional argument mode="L" to automatically convert it to +grayscale. Next, the grayscale image is blurred with a Gaussian filter +with the value of sigma that is passed to the function. +Then we determine the threshold t with Otsu’s method and +create a binary mask just as we did in the previous section. Up to this +point, everything should be familiar.

+

The final part of the function determines the root mass ratio in the +image. Recall that in the binary_mask, every pixel has +either a value of zero (black/background) or one (white/foreground). We +want to count the number of white pixels, which can be accomplished with +a call to the NumPy function np.count_nonzero. Finally, the +density ratio is calculated by dividing the number of white pixels by +the total number of pixels binary_mask.size in the image. +The function returns then root density of the image.

+

We can call this function with any filename and provide a sigma value +for the blurring. If no sigma value is provided, the default value 1.0 +will be used. For example, for the file data/trial-016.jpg +and a sigma value of 1.5, we would call the function like this:

+
+

PYTHON +

+
measure_root_mass(filename="data/trial-016.jpg", sigma=1.5)
+
+
+

OUTPUT +

+
0.04907247340425532
+
+

Now we can use the function to process the series of four images +shown above. In a real-world scientific situation, there might be +dozens, hundreds, or even thousands of images to process. To save us the +tedium of calling the function for each image by hand, we can write a +loop that processes all files automatically. The following code block +assumes that the files are located in the same directory and the +filenames all start with the trial- prefix and end with +the .jpg suffix.

+
+

PYTHON +

+
all_files = sorted(glob.glob("data/trial-*.jpg"))
+for filename in all_files:
+    density = measure_root_mass(filename=filename, sigma=1.5)
+    # output in format suitable for .csv
+    print(filename, density, sep=",")
+
+
+

OUTPUT +

+
data/trial-016.jpg,0.04907247340425532
+data/trial-020.jpg,0.06381366356382978
+data/trial-216.jpg,0.14205152925531914
+data/trial-293.jpg,0.13665791223404256
+
+
+
+ +
+Callout +
+
+

Compare your results with the values above. Do they match exactly? +You may find that certain decimal values differ slightly, even when +using identical input parameters.

+

This variation often stems from the specific versions of your +installed packages (such as numpy or +scikit-image). As these libraries evolve, updates can +introduce subtle changes in numerical handling, underlying algorithms, +or rounding logic. This highlights why reproducible environments, as +well as reproducible code, are essential for consistent scientific +computing.

+
+
+
+
+
+ +
+Challenge +
+

Ignoring more of the images – brainstorming +(10 min)

+
+

Let us take a closer look at the binary masks produced by the +measure_root_mass function.

+
Binary masks of the four maize root images

You may have noticed in the section on automatic thresholding that +the thresholded image does include regions of the image aside of the +plant root: the numbered labels and the white circles in each image are +preserved during the thresholding, because their grayscale values are +above the threshold. Therefore, our calculated root mass ratios include +the white pixels of the label and white circle that are not part of the +plant root. Those extra pixels affect how accurate the root mass +calculation is!

+

How might we remove the labels and circles before calculating the +ratio, so that our results are more accurate? Think about some options +given what we have learned so far.

+
+
+
+
+
+ +
+
+

One approach we might take is to try to completely mask out a region +from each image, particularly, the area containing the white circle and +the numbered label. If we had coordinates for a rectangular area on the +image that contained the circle and the label, we could mask the area +out by using techniques we learned in the +Drawing and Bitwise Operations episode.

+

However, a closer inspection of the binary images raises some issues +with that approach. Since the roots are not always constrained to a +certain area in the image, and since the circles and labels are in +different locations each time, we would have difficulties coming up with +a single rectangle that would work for every image. We could +create a different masking rectangle for each image, but that is not a +practicable approach if we have hundreds or thousands of images to +process.

+

Another approach we could take is to apply two thresholding steps to +the image. Look at the graylevel histogram of the file +data/trial-016.jpg shown above again: Notice the peak near +1.0? Recall that a grayscale value of 1.0 corresponds to white pixels: +the peak corresponds to the white label and circle. So, we could use +simple binary thresholding to mask the white circle and label from the +image, and then we could use Otsu’s method to select the pixels in the +plant portion of the image.

+

Note that most of this extra work in processing the image could have +been avoided during the experimental design stage, with some careful +consideration of how the resulting images would be used. For example, +all of the following measures could have made the images easier to +process, by helping us predict and/or detect where the label is in the +image and subsequently mask it from further processing:

+
    +
  • Using labels with a consistent size and shape
  • +
  • Placing all the labels in the same position, relative to the +sample
  • +
  • Using a non-white label, with non-black writing
  • +
+
+
+
+
+
+
+ +
+Challenge +
+

Ignoring more of the images – implementation +(30 min - optional, not included in timing)

+
+

Implement an enhanced version of the function +measure_root_mass that applies simple binary thresholding +to remove the white circle and label from the image before applying +Otsu’s method.

+
+
+
+
+
+ +
+
+

We can apply a simple binary thresholding with a threshold +t=0.95 to remove the label and circle from the image. We +can then use the binary mask to calculate the Otsu threshold without the +pixels from the label and circle.

+
+

PYTHON +

+
def enhanced_root_mass(filename, sigma):
+
+    # read the original image, converting to grayscale on the fly
+    image = iio.imread(uri=filename, mode="L")
+
+    # blur before thresholding
+    blurred_image = ski.filters.gaussian(image, sigma=sigma)
+
+    # perform binary thresholding to mask the white label and circle
+    binary_mask = blurred_image < 0.95
+
+    # perform automatic thresholding using only the pixels with value True in the binary mask
+    t = ski.filters.threshold_otsu(blurred_image[binary_mask])
+
+    # update binary mask to identify pixels which are both less than 0.95 and greater than t
+    binary_mask = (blurred_image < 0.95) & (blurred_image > t)
+
+    # determine root mass ratio
+    root_pixels = np.count_nonzero(binary_mask)
+    density = root_pixels / binary_mask.size
+
+    return density
+
+
+all_files = sorted(glob.glob("data/trial-*.jpg"))
+for filename in all_files:
+    density = enhanced_root_mass(filename=filename, sigma=1.5)
+    # output in format suitable for .csv
+    print(filename, density, sep=",")
+
+

The output of the improved program does illustrate that the white +circles and labels were skewing our root mass ratios:

+
+

OUTPUT +

+
data/trial-016.jpg,0.046261136968085106
+data/trial-020.jpg,0.05887167553191489
+data/trial-216.jpg,0.13712067819148935
+data/trial-293.jpg,0.1319044215425532
+
+
+
+ +
+
+

The & operator above means that we have defined a +logical AND statement. This combines the two tests of pixel intensities +in the blurred image such that both must be true for a pixel’s position +to be set to True in the resulting mask.

+ +++++ + + + + + + + + + + + + + + + + + + + + + + +
Result of t < blurred_image +Result of blurred_image < 0.95 +Resulting value in binary_mask +
FalseTrueFalse
TrueFalseFalse
TrueTrueTrue
+

Knowing how to construct this kind of logical operation can be very +helpful in image processing. The University of Minnesota Library’s guide to Boolean +operators is a good place to start if you want to learn more.

+
+
+
+
+

Here are the binary images produced by the additional thresholding. +Note that we have not completely removed the offending white pixels. +Outlines still remain. However, we have reduced the number of extraneous +pixels, which should make the output more accurate.

+
Improved binary masks of the four maize root images
+
+
+
+
+
+
+ +
+Challenge +
+

Thresholding a bacteria colony image (15 +min)

+
+

In the images directory data/, you will find an image +named colonies-01.tif.

+
Image of bacteria colonies in a petri dish

This is one of the images you will be working with in the +morphometric challenge at the end of the workshop.

+
    +
  1. Plot and inspect the grayscale histogram of the image to determine a +good threshold value for the image.
  2. +
  3. Create a binary mask that leaves the pixels in the bacteria colonies +“on” while turning the rest of the pixels in the image “off”.
  4. +
+
+
+
+
+
+ +
+
+

Here is the code to create the grayscale histogram:

+
+

PYTHON +

+
bacteria = iio.imread(uri="data/colonies-01.tif")
+gray_image = ski.color.rgb2gray(bacteria)
+blurred_image = ski.filters.gaussian(gray_image, sigma=1.0)
+histogram, bin_edges = np.histogram(blurred_image, bins=256, range=(0.0, 1.0))
+fig, ax = plt.subplots()
+ax.plot(bin_edges[0:-1], histogram)
+ax.set_title("Graylevel histogram")
+ax.set_xlabel("gray value")
+ax.set_ylabel("pixel count")
+ax.set_xlim(0, 1.0)
+
+
Grayscale histogram of the bacteria colonies image

The peak near one corresponds to the white image background, and the +broader peak around 0.5 corresponds to the yellow/brown culture medium +in the dish. The small peak near zero is what we are after: the dark +bacteria colonies. A reasonable choice thus might be to leave pixels +below t=0.2 on.

+

Here is the code to create and show the binarized image using the +< operator with a threshold t=0.2:

+
+

PYTHON +

+
t = 0.2
+binary_mask = blurred_image < t
+
+fig, ax = plt.subplots()
+ax.imshow(binary_mask, cmap="gray")
+
+
Binary mask of the bacteria colonies image

When you experiment with the threshold a bit, you can see that in +particular the size of the bacteria colony near the edge of the dish in +the top right is affected by the choice of the threshold.

+
+
+
+
+
+
+ +
+Key Points +
+
+
    +
  • Thresholding produces a binary image, where all pixels with +intensities above (or below) a threshold value are turned on, while all +other pixels are turned off.
  • +
  • The binary images produced by thresholding are held in +two-dimensional NumPy arrays, since they have only one colour value +channel. They are boolean, hence they contain the values 0 (off) and 1 +(on).
  • +
  • Thresholding can be used to create masks that select only the +interesting parts of an image, or as the first step before edge +detection or finding contours.
  • +
+
+
+
+

Content from Connected Component Analysis

+
+

Last updated on 2026-03-20 | + + Edit this page

+

Estimated time: 125 minutes

+
+ +
+
+

Overview

+
+
+
+
+

Questions

+
    +
  • How to extract separate objects from an image and describe these +objects quantitatively.
  • +
+
+
+
+
+
+
+

Objectives

+
    +
  • Understand the term object in the context of images.
  • +
  • Learn about pixel connectivity.
  • +
  • Learn how Connected Component Analysis (CCA) works.
  • +
  • Use CCA to produce an image that highlights every object in a +different colour.
  • +
  • Characterise each object with numbers that describe its +appearance.
  • +
+
+
+
+
+
+

Objects +

+
+

In the Thresholding +episode we have covered dividing an image into foreground and +background pixels. In the shapes example image, we considered the +coloured shapes as foreground objects on a white +background.

+
Original shapes image

In thresholding we went from the original image to this version:

+
Mask created by thresholding

Here, we created a mask that only highlights the parts of the image +that we find interesting, the objects. All objects have pixel +value of True while the background pixels are +False.

+

By looking at the mask image, one can count the objects that are +present in the image (7). But how did we actually do that, how did we +decide which lump of pixels constitutes a single object?

+ +

Pixel Neighborhoods +

+
+

In order to decide which pixels belong to the same object, one can +exploit their neighborhood: pixels that are directly next to each other +and belong to the foreground class can be considered to belong to the +same object.

+

Let’s discuss the concept of pixel neighborhoods in more detail. +Consider the following mask “image” with 8 rows, and 8 columns. For the +purpose of illustration, the digit 0 is used to represent +background pixels, and the letter X is used to represent +object pixels foreground).

+
+

OUTPUT +

+
0 0 0 0 0 0 0 0
+0 X X 0 0 0 0 0
+0 X X 0 0 0 0 0
+0 0 0 X X X 0 0
+0 0 0 X X X X 0
+0 0 0 0 0 0 0 0
+
+

The pixels are organised in a rectangular grid. In order to +understand pixel neighborhoods we will introduce the concept of “jumps” +between pixels. The jumps follow two rules: First rule is that one jump +is only allowed along the column, or the row. Diagonal jumps are not +allowed. So, from a centre pixel, denoted with o, only the +pixels indicated with a 1 are reachable:

+
+

OUTPUT +

+
- 1 -
+1 o 1
+- 1 -
+
+

The pixels on the diagonal (from o) are not reachable +with a single jump, which is denoted by the -. The pixels +reachable with a single jump form the 1-jump +neighborhood.

+

The second rule states that in a sequence of jumps, one may only jump +in row and column direction once -> they have to be +orthogonal. An example of a sequence of orthogonal jumps is +shown below. Starting from o the first jump goes along the +row to the right. The second jump then goes along the column direction +up. After this, the sequence cannot be continued as a jump has already +been made in both row and column direction.

+
+

OUTPUT +

+
- - 2
+- o 1
+- - -
+
+

All pixels reachable with one, or two jumps form the +2-jump neighborhood. The grid below illustrates the +pixels reachable from the centre pixel o with a single +jump, highlighted with a 1, and the pixels reachable with 2 +jumps with a 2.

+
+

OUTPUT +

+
2 1 2
+1 o 1
+2 1 2
+
+

We want to revisit our example image mask from above and apply the +two different neighborhood rules. With a single jump connectivity for +each pixel, we get two resulting objects, highlighted in the image with +A’s and B’s.

+
+

OUTPUT +

+
0 0 0 0 0 0 0 0
+0 A A 0 0 0 0 0
+0 A A 0 0 0 0 0
+0 0 0 B B B 0 0
+0 0 0 B B B B 0
+0 0 0 0 0 0 0 0
+
+

In the 1-jump version, only pixels that have direct neighbors along +rows or columns are considered connected. Diagonal connections are not +included in the 1-jump neighborhood. With two jumps, however, we only +get a single object A because pixels are also considered +connected along the diagonals.

+
+

OUTPUT +

+
0 0 0 0 0 0 0 0
+0 A A 0 0 0 0 0
+0 A A 0 0 0 0 0
+0 0 0 A A A 0 0
+0 0 0 A A A A 0
+0 0 0 0 0 0 0 0
+
+
+
+ +
+Challenge +
+

Object counting (optional, not included in +timing)

+
+

How many objects with 1 orthogonal jump, how many with 2 orthogonal +jumps?

+
+

OUTPUT +

+
0 0 0 0 0 0 0 0
+0 X 0 0 0 X X 0
+0 0 X 0 0 0 0 0
+0 X 0 X X X 0 0
+0 X 0 X X 0 0 0
+0 0 0 0 0 0 0 0
+
+

1 jump

+
    +
  1. 1
  2. +
  3. 5
  4. +
  5. 2
  6. +
+
+
+
+
+
+ +
+
+
    +
  1. 5
  2. +
+
+
+
+
+
+
+ +
+Challenge +
+

Object counting (optional, not included in +timing) (continued) +

+
+

2 jumps

+
    +
  1. 2
  2. +
  3. 3
  4. +
  5. 5
  6. +
+
+
+
+
+
+ +
+
+
    +
  1. 2
  2. +
+
+
+
+
+
+
+ +
+Callout +
+

Jumps and neighborhoods

+
+

We have just introduced how you can reach different neighboring +pixels by performing one or more orthogonal jumps. We have used the +terms 1-jump and 2-jump neighborhood. There is also a different way of +referring to these neighborhoods: the 4- and 8-neighborhood. With a +single jump you can reach four pixels from a given starting pixel. +Hence, the 1-jump neighborhood corresponds to the 4-neighborhood. When +two orthogonal jumps are allowed, eight pixels can be reached, so the +2-jump neighborhood corresponds to the 8-neighborhood.

+
+
+
+

Connected Component Analysis +

+
+

In order to find the objects in an image, we want to employ an +operation that is called Connected Component Analysis (CCA). This +operation takes a binary image as an input. Usually, the +False value in this image is associated with background +pixels, and the True value indicates foreground, or object +pixels. Such an image can be produced, e.g., with thresholding. Given a +thresholded image, the connected component analysis produces a new +labeled image with integer pixel values. Pixels with the same +value, belong to the same object. scikit-image provides connected +component analysis in the function ski.measure.label(). Let +us add this function to the already familiar steps of thresholding an +image.

+

First, import the packages needed for this episode:

+
+

PYTHON +

+
import imageio.v3 as iio
+import ipympl
+import matplotlib.pyplot as plt
+import numpy as np
+import skimage as ski
+
+%matplotlib widget
+
+

In this episode, we will use the ski.measure.label +function to perform the CCA.

+

Next, we define a reusable Python function +connected_components:

+
+

PYTHON +

+
def connected_components(filename, sigma=1.0, t=0.5, connectivity=2):
+    # load the image
+    image = iio.imread(filename)
+    # convert the image to grayscale
+    gray_image = ski.color.rgb2gray(image)
+    # denoise the image with a Gaussian filter
+    blurred_image = ski.filters.gaussian(gray_image, sigma=sigma)
+    # mask the image according to threshold
+    binary_mask = blurred_image < t
+    # perform connected component analysis
+    labeled_image, count = ski.measure.label(binary_mask,
+                                                 connectivity=connectivity, return_num=True)
+    return labeled_image, count
+
+

The first four lines of code are familiar from the Thresholding episode.

+ +

Then we call the ski.measure.label function. This +function has one positional argument where we pass the +binary_mask, i.e., the binary image to work on. With the +optional argument connectivity, we specify the neighborhood +in units of orthogonal jumps. For example, by setting +connectivity=2 we will consider the 2-jump neighborhood +introduced above. The function returns a labeled_image +where each pixel has a unique value corresponding to the object it +belongs to. In addition, we pass the optional parameter +return_num=True to return the maximum label index as +count.

+
+
+ +
+Callout +
+

Optional parameters and return values

+
+

The optional parameter return_num changes the data type +that is returned by the function ski.measure.label. The +number of labels is only returned if return_num is +True. Otherwise, the function only returns the labeled image. +This means that we have to pay attention when assigning the return value +to a variable. If we omit the optional parameter return_num +or pass return_num=False, we can call the function as

+
+

PYTHON +

+
labeled_image = ski.measure.label(binary_mask)
+
+

If we pass return_num=True, the function returns a tuple +and we can assign it as

+
+

PYTHON +

+
labeled_image, count = ski.measure.label(binary_mask, return_num=True)
+
+

If we used the same assignment as in the first case, the variable +labeled_image would become a tuple, in which +labeled_image[0] is the image and +labeled_image[1] is the number of labels. This could cause +confusion if we assume that labeled_image only contains the +image and pass it to other functions. If you get an +AttributeError: 'tuple' object has no attribute 'shape' or +similar, check if you have assigned the return values consistently with +the optional parameters.

+
+
+
+

We can call the above function connected_components and +display the labeled image like so:

+
+

PYTHON +

+
labeled_image, count = connected_components(filename="data/shapes-01.jpg", sigma=2.0, t=0.9, connectivity=2)
+
+fig, ax = plt.subplots()
+ax.imshow(labeled_image)
+ax.set_axis_off();
+
+
+
+ +
+
+

If you are using an older version of Matplotlib you might get a +warning +UserWarning: Low image data range; displaying image with stretched contrast. +or just see a visually empty image.

+

What went wrong? When you hover over the image, the pixel values are +shown as numbers in the lower corner of the viewer. You can see that +some pixels have values different from 0, so they are not +actually all the same value. Let’s find out more by examining +labeled_image. Properties that might be interesting in this +context are dtype, the minimum and maximum value. We can +print them with the following lines:

+
+

PYTHON +

+
print("dtype:", labeled_image.dtype)
+print("min:", np.min(labeled_image))
+print("max:", np.max(labeled_image))
+
+

Examining the output can give us a clue why the image appears +empty.

+
+

OUTPUT +

+
dtype: int32
+min: 0
+max: 11
+
+

The dtype of labeled_image is +int32. This means that values in this image range from +-2 ** 31 to 2 ** 31 - 1. Those are really big +numbers. From this available space we only use the range from +0 to 11. When showing this image in the +viewer, it may squeeze the complete range into 256 gray values. +Therefore, the range of our numbers does not produce any visible +variation. One way to rectify this is to explicitly specify the data +range we want the colormap to cover:

+
+

PYTHON +

+
fig, ax = plt.subplots()
+ax.imshow(labeled_image, vmin=np.min(labeled_image), vmax=np.max(labeled_image))
+
+

Note this is the default behaviour for newer versions of +matplotlib.pyplot.imshow. Alternatively we could convert +the image to RGB and then display it.

+
+
+
+
+
+
+ +
+Callout +
+

Suppressing outputs in Jupyter Notebooks

+
+

We just used ax.set_axis_off(); to hide the axis from +the image for a visually cleaner figure. The semicolon is added to +supress the output(s) of the statement, in this case +the axis limits. This is specific to Jupyter Notebooks.

+
+
+
+

We can use the function ski.color.label2rgb() to convert +the 32-bit grayscale labeled image to standard RGB colour (recall that +we already used the ski.color.rgb2gray() function to +convert to grayscale). With ski.color.label2rgb(), all +objects are coloured according to a list of colours that can be +customised. We can use the following commands to convert and show the +image:

+
+

PYTHON +

+
# convert the label image to color image
+colored_label_image = ski.color.label2rgb(labeled_image, bg_label=0)
+
+fig, ax = plt.subplots()
+ax.imshow(colored_label_image)
+ax.set_axis_off();
+
+
Labeled objects
+
+ +
+Challenge +
+

How many objects are in that image (15 +min)

+
+

Now, it is your turn to practice. Using the function +connected_components, find two ways of printing out the +number of objects found in the image.

+

What number of objects would you expect to get?

+

How does changing the sigma and threshold +values influence the result?

+
+
+
+
+
+ +
+
+

As you might have guessed, the return value count +already contains the number of objects found in the image. So it can +simply be printed with

+
+

PYTHON +

+
print("Found", count, "objects in the image.")
+
+

But there is also a way to obtain the number of found objects from +the labeled image itself. Recall that all pixels that belong to a single +object are assigned the same integer value. The connected component +algorithm produces consecutive numbers. The background gets the value +0, the first object gets the value 1, the +second object the value 2, and so on. This means that by +finding the object with the maximum value, we also know how many objects +there are in the image. We can thus use the np.max function +from NumPy to find the maximum value that equals the number of found +objects:

+
+

PYTHON +

+
num_objects = np.max(labeled_image)
+print("Found", num_objects, "objects in the image.")
+
+

Invoking the function with sigma=2.0, and +threshold=0.9, both methods will print

+
+

OUTPUT +

+
Found 11 objects in the image.
+
+

Lowering the threshold will result in fewer objects. The higher the +threshold is set, the more objects are found. More and more background +noise gets picked up as objects. Larger sigmas produce binary masks with +less noise and hence a smaller number of objects. Setting sigma too high +bears the danger of merging objects.

+
+
+
+
+

You might wonder why the connected component analysis with +sigma=2.0, and threshold=0.9 finds 11 objects, +whereas we would expect only 7 objects. Where are the four additional +objects? With a bit of detective work, we can spot some small objects in +the image, for example, near the left border.

+
shapes-01.jpg mask detail

For us it is clear that these small spots are artifacts and not +objects we are interested in. But how can we tell the computer? One way +to calibrate the algorithm is to adjust the parameters for blurring +(sigma) and thresholding (t), but you may have +noticed during the above exercise that it is quite hard to find a +combination that produces the right output number. In some cases, +background noise gets picked up as an object. And with other parameters, +some of the foreground objects get broken up or disappear completely. +Therefore, we need other criteria to describe desired properties of the +objects that are found.

+

Morphometrics - Describe object features with numbers +

+
+

Morphometrics is concerned with the quantitative analysis of objects +and considers properties such as size and shape. For the example of the +images with the shapes, our intuition tells us that the objects should +be of a certain size or area. So we could use a minimum area as a +criterion for when an object should be detected. To apply such a +criterion, we need a way to calculate the area of objects found by +connected components. Recall how we determined the root mass in the Thresholding episode by +counting the pixels in the binary mask. But here we want to calculate +the area of several objects in the labeled image. The scikit-image +library provides the function ski.measure.regionprops to +measure the properties of labeled regions. It returns a list of +RegionProperties that describe each connected region in the +images. The properties can be accessed using the attributes of the +RegionProperties data type. Here we will use the properties +"area" and "label". You can explore the +scikit-image documentation to learn about other properties +available.

+

We can get a list of areas of the labeled objects as follows:

+
+

PYTHON +

+
# compute object features and extract object areas
+object_features = ski.measure.regionprops(labeled_image)
+object_areas = [objf["area"] for objf in object_features]
+object_areas
+
+

This will produce the output

+
+

OUTPUT +

+
[318542, 1, 523204, 496613, 517331, 143, 256215, 1, 68, 338784, 265755]
+
+
+
+ +
+Challenge +
+

Plot a histogram of the object area +distribution (10 min)

+
+

Similar to how we determined a “good” threshold in the Thresholding episode, it is +often helpful to inspect the histogram of an object property. For +example, we want to look at the distribution of the object areas.

+
    +
  1. Create and examine a histogram of the object areas +obtained with ski.measure.regionprops.
  2. +
  3. What does the histogram tell you about the objects?
  4. +
+
+
+
+
+
+ +
+
+

The histogram can be plotted with

+
+

PYTHON +

+
fig, ax = plt.subplots()
+ax.hist(object_areas)
+ax.set_xlabel("Area (pixels)")
+ax.set_ylabel("Number of objects");
+
+
Histogram of object areas

The histogram shows the number of objects (vertical axis) whose area +is within a certain range (horizontal axis). The height of the bars in +the histogram indicates the prevalence of objects with a certain area. +The whole histogram tells us about the distribution of object sizes in +the image. It is often possible to identify gaps between groups of bars +(or peaks if we draw the histogram as a continuous curve) that tell us +about certain groups in the image.

+

In this example, we can see that there are four small objects that +contain less than 50000 pixels. Then there is a group of four (1+1+2) +objects in the range between 200000 and 400000, and three objects with a +size around 500000. For our object count, we might want to disregard the +small objects as artifacts, i.e, we want to ignore the leftmost bar of +the histogram. We could use a threshold of 50000 as the minimum area to +count. In fact, the object_areas list already tells us that +there are fewer than 200 pixels in these objects. Therefore, it is +reasonable to require a minimum area of at least 200 pixels for a +detected object. In practice, finding the “right” threshold can be +tricky and usually involves an educated guess based on domain +knowledge.

+
+
+
+
+
+
+ +
+Challenge +
+

Filter objects by area (10 min)

+
+

Now we would like to use a minimum area criterion to obtain a more +accurate count of the objects in the image.

+
    +
  1. Find a way to calculate the number of objects by only counting +objects above a certain area.
  2. +
+
+
+
+
+
+ +
+
+

One way to count only objects above a certain area is to first create +a list of those objects, and then take the length of that list as the +object count. This can be done as follows:

+
+

PYTHON +

+
min_area = 200
+large_objects = []
+for objf in object_features:
+    if objf["area"] > min_area:
+        large_objects.append(objf["label"])
+print("Found", len(large_objects), "objects!")
+
+

Another option is to use NumPy arrays to create the list of large +objects. We first create an array object_areas containing +the object areas, and an array object_labels containing the +object labels. The labels of the objects are also returned by +ski.measure.regionprops. We have already seen that we can +create boolean arrays using comparison operators. Here we can use +object_areas > min_area to produce an array that has the +same dimension as object_labels. It can then be used to +select the labels of objects whose area is greater than +min_area by indexing:

+
+

PYTHON +

+
object_areas = np.array([objf["area"] for objf in object_features])
+object_labels = np.array([objf["label"] for objf in object_features])
+large_objects = object_labels[object_areas > min_area]
+print("Found", len(large_objects), "objects!")
+
+

The advantage of using NumPy arrays is that for loops +and if statements in Python can be slow, and in practice +the first approach may not be feasible if the image contains a large +number of objects. In that case, NumPy array functions turn out to be +very useful because they are much faster.

+

In this example, we can also use the np.count_nonzero +function that we have seen earlier together with the > +operator to count the objects whose area is above +min_area.

+
+

PYTHON +

+
n = np.count_nonzero(object_areas > min_area)
+print("Found", n, "objects!")
+
+

For all three alternatives, the output is the same and gives the +expected count of 7 objects.

+
+
+
+
+
+
+ +
+Callout +
+

Using functions from NumPy and other Python +packages

+
+

Functions from Python packages such as NumPy are often more efficient +and require less code to write. It is a good idea to browse the +reference pages of numpy and skimage to look +for an availabe function that can solve a given task.

+
+
+
+
+
+ +
+Challenge +
+

Remove small objects (20 min)

+
+

We might also want to exclude (mask) the small objects when plotting +the labeled image.

+
    +
  1. Enhance the connected_components function such that it +automatically removes objects that are below a certain area that is +passed to the function as an optional parameter.
  2. +
+
+
+
+
+
+ +
+
+

To remove the small objects from the labeled image, we change the +value of all pixels that belong to the small objects to the background +label 0. One way to do this is to loop over all objects and set the +pixels that match the label of the object to 0.

+
+

PYTHON +

+
for object_id, objf in enumerate(object_features, start=1):
+    if objf["area"] < min_area:
+        labeled_image[labeled_image == objf["label"]] = 0
+
+

Here NumPy functions can also be used to eliminate for +loops and if statements. Like above, we can create an array +of the small object labels with the comparison +object_areas < min_area. We can use another NumPy +function, np.isin, to set the pixels of all small objects +to 0. np.isin takes two arrays and returns a boolean array +with values True if the entry of the first array is found +in the second array, and False otherwise. This array can +then be used to index the labeled_image and set the entries +that belong to small objects to 0.

+
+

PYTHON +

+
object_areas = np.array([objf["area"] for objf in object_features])
+object_labels = np.array([objf["label"] for objf in object_features])
+small_objects = object_labels[object_areas < min_area]
+labeled_image[np.isin(labeled_image, small_objects)] = 0
+
+

An even more elegant way to remove small objects from the image is to +leverage the ski.morphology module. It provides a function +ski.morphology.remove_small_objects that does exactly what +we are looking for. It can be applied to a binary image and returns a +mask in which all objects smaller than min_area are +excluded, i.e, their pixel values are set to False. We can +then apply ski.measure.label to the masked image:

+
+

PYTHON +

+
object_mask = ski.morphology.remove_small_objects(binary_mask, min_size=min_area)
+labeled_image, n = ski.measure.label(object_mask,
+                                         connectivity=connectivity, return_num=True)
+
+

Using the scikit-image features, we can implement the +enhanced_connected_component as follows:

+
+

PYTHON +

+
def enhanced_connected_components(filename, sigma=1.0, t=0.5, connectivity=2, min_area=0):
+    image = iio.imread(filename)
+    gray_image = ski.color.rgb2gray(image)
+    blurred_image = ski.filters.gaussian(gray_image, sigma=sigma)
+    binary_mask = blurred_image < t
+    object_mask = ski.morphology.remove_small_objects(binary_mask, min_size=min_area)
+    labeled_image, count = ski.measure.label(object_mask,
+                                                 connectivity=connectivity, return_num=True)
+    return labeled_image, count
+
+

We can now call the function with a chosen min_area and +display the resulting labeled image:

+
+

PYTHON +

+
labeled_image, count = enhanced_connected_components(filename="data/shapes-01.jpg", sigma=2.0, t=0.9,
+                                                     connectivity=2, min_area=min_area)
+colored_label_image = ski.color.label2rgb(labeled_image, bg_label=0)
+
+fig, ax = plt.subplots()
+ax.imshow(colored_label_image)
+ax.set_axis_off();
+
+print("Found", count, "objects in the image.")
+
+
Objects filtered by area
+

OUTPUT +

+
Found 7 objects in the image.
+
+

Note that the small objects are “gone” and we obtain the correct +number of 7 objects in the image.

+
+
+
+
+
+
+ +
+Challenge +
+

Colour objects by area (optional, not included +in timing)

+
+

Finally, we would like to display the image with the objects coloured +according to the magnitude of their area. In practice, this can be used +with other properties to give visual cues of the object properties.

+
+
+
+
+
+ +
+
+

We already know how to get the areas of the objects from the +regionprops. We just need to insert a zero area value for +the background (to colour it like a zero size object). The background is +also labeled 0 in the labeled_image, so we +insert the zero area value in front of the first element of +object_areas with np.insert. Then we can +create a colored_area_image where we assign each pixel +value the area by indexing the object_areas with the label +values in labeled_image.

+
+

PYTHON +

+
object_areas = np.array([objf["area"] for objf in ski.measure.regionprops(labeled_image)])
+# prepend zero to object_areas array for background pixels
+object_areas = np.insert(0, obj=1, values=object_areas)
+# create image where the pixels in each object are equal to that object's area
+colored_area_image = object_areas[labeled_image]
+
+fig, ax = plt.subplots()
+im = ax.imshow(colored_area_image)
+cbar = fig.colorbar(im, ax=ax, shrink=0.85)
+cbar.ax.set_title("Area")
+ax.set_axis_off();
+
+
Objects colored by area
+
+ +
+Callout +
+
+

You may have noticed that in the solution, we have used the +labeled_image to index the array object_areas. +This is an example of advanced +indexing in NumPy The result is an array of the same shape as the +labeled_image whose pixel values are selected from +object_areas according to the object label. Hence the +objects will be colored by area when the result is displayed. Note that +advanced indexing with an integer array works slightly different than +the indexing with a Boolean array that we have used for masking. While +Boolean array indexing returns only the entries corresponding to the +True values of the index, integer array indexing returns an +array with the same shape as the index. You can read more about advanced +indexing in the NumPy +documentation.

+
+
+
+
+
+
+
+
+
+ +
+Key Points +
+
+
    +
  • We can use ski.measure.label to find and label +connected objects in an image.
  • +
  • We can use ski.measure.regionprops to measure +properties of labeled objects.
  • +
  • We can use ski.morphology.remove_small_objects to mask +small objects and remove artifacts from an image.
  • +
  • We can display the labeled image to view the objects coloured by +label.
  • +
+
+
+
+

Content from Capstone Challenge

+
+

Last updated on 2026-03-23 | + + Edit this page

+

Estimated time: 50 minutes

+
+ +
+
+

Overview

+
+
+
+
+

Questions

+
    +
  • How can we automatically count bacterial colonies with image +analysis?
  • +
+
+
+
+
+
+
+

Objectives

+
    +
  • Bring together everything you’ve learnt so far to count bacterial +colonies in 3 images.
  • +
+
+
+
+
+
+

In this episode, we will provide a final challenge for you to +attempt, based on all the skills you have acquired so far. This +challenge will be related to the shape of objects in images +(morphometrics).

+

Morphometrics: Bacteria Colony Counting +

+
+

As mentioned in the workshop +introduction, your morphometric challenge is to determine how many +bacteria colonies are in each of these images:

+
Colony image 1
Colony image 2
Colony image 3

The image files can be found at data/colonies-01.tif, +data/colonies-02.tif, and +data/colonies-03.tif.

+
+
+ +
+Challenge +
+

Morphometrics for bacterial colonies

+
+

Write a Python program that uses scikit-image to count the number of +bacteria colonies in each image, and for each, produce a new image that +highlights the colonies. The image should look similar to this one:

+
Sample morphometric output

Additionally, print out the number of colonies for each image.

+

Use what you have learnt about histograms, thresholding and connected component analysis. +Try to put your code into a re-usable function, so that it can be +applied conveniently to any image file.

+
+
+
+
+
+ +
+
+

First, let’s work through the process for one image:

+
+

PYTHON +

+
import imageio.v3 as iio
+import ipympl
+import matplotlib.pyplot as plt
+import numpy as np
+import skimage as ski
+
+%matplotlib widget
+
+bacteria_image = iio.imread(uri="data/colonies-01.tif")
+
+# display the image
+fig, ax = plt.subplots()
+ax.imshow(bacteria_image)
+
+
Colony image 1

Next, we need to threshold the image to create a mask that covers +only the dark bacterial colonies. This is easier using a grayscale +image, so we convert it here:

+
+

PYTHON +

+
gray_bacteria = ski.color.rgb2gray(bacteria_image)
+
+# display the gray image
+fig, ax = plt.subplots()
+ax.imshow(gray_bacteria, cmap="gray")
+
+
Gray Colonies

Next, we blur the image and create a histogram:

+
+

PYTHON +

+
blurred_image = ski.filters.gaussian(gray_bacteria, sigma=1.0)
+histogram, bin_edges = np.histogram(blurred_image, bins=256, range=(0.0, 1.0))
+fig, ax = plt.subplots()
+ax.plot(bin_edges[0:-1], histogram)
+ax.set_title("Graylevel histogram")
+ax.set_xlabel("gray value")
+ax.set_ylabel("pixel count")
+ax.set_xlim(0, 1.0)
+
+
Histogram image

In this histogram, we see three peaks - the left one (i.e. the +darkest pixels) is our colonies, the central peak is the yellow/brown +culture medium in the dish, and the right one (i.e. the brightest +pixels) is the white image background. Therefore, we choose a threshold +that selects the small left peak:

+
+

PYTHON +

+
mask = blurred_image < 0.2
+fig, ax = plt.subplots()
+ax.imshow(mask, cmap="gray")
+
+
Colony mask image

This mask shows us where the colonies are in the image - but how can +we count how many there are? This requires connected component +analysis:

+
+

PYTHON +

+
labeled_image, count = ski.measure.label(mask, return_num=True)
+print(count)
+
+

Finally, we create the summary image of the coloured colonies on top +of the grayscale image:

+
+

PYTHON +

+
# color each of the colonies a different color
+colored_label_image = ski.color.label2rgb(labeled_image, bg_label=0)
+# give our grayscale image rgb channels, so we can add the colored colonies
+summary_image = ski.color.gray2rgb(gray_bacteria)
+summary_image[mask] = colored_label_image[mask]
+
+# plot overlay
+fig, ax = plt.subplots()
+ax.imshow(summary_image)
+
+
Sample morphometric output

Now that we’ve completed the task for one image, we need to repeat +this for the remaining two images. This is a good point to collect the +lines above into a re-usable function:

+
+

PYTHON +

+
def count_colonies(image_filename):
+    bacteria_image = iio.imread(image_filename)
+    gray_bacteria = ski.color.rgb2gray(bacteria_image)
+    blurred_image = ski.filters.gaussian(gray_bacteria, sigma=1.0)
+    mask = blurred_image < 0.2
+    labeled_image, count = ski.measure.label(mask, return_num=True)
+    print(f"There are {count} colonies in {image_filename}")
+
+    colored_label_image = ski.color.label2rgb(labeled_image, bg_label=0)
+    summary_image = ski.color.gray2rgb(gray_bacteria)
+    summary_image[mask] = colored_label_image[mask]
+    fig, ax = plt.subplots()
+    ax.imshow(summary_image)
+
+

Now we can do this analysis on all the images via a for loop:

+
+

PYTHON +

+
for image_filename in ["data/colonies-01.tif", "data/colonies-02.tif", "data/colonies-03.tif"]:
+    count_colonies(image_filename=image_filename)
+
+

Colony 1 outputColony 2 outputColony 3 output

+

You’ll notice that for the images with more colonies, the results +aren’t perfect. For example, some small colonies are missing, and there +are likely some small black spots being labelled incorrectly as +colonies. You could expand this solution to, for example, use an +automatically determined threshold for each image, which may fit each +better. Also, you could filter out colonies below a certain size (as we +did in the Connected +Component Analysis episode). You’ll also see that some touching +colonies are merged into one big colony. This could be fixed with more +complicated segmentation methods (outside of the scope of this lesson) +like watershed.

+
+
+
+
+
+
+ +
+Challenge +
+

Colony counting with minimum size and +automated threshold (optional, not included in timing)

+
+

Modify your function from the previous exercise for colony counting +to (i) exclude objects smaller than a specified size and (ii) use an +automated thresholding approach, e.g. Otsu, to mask the colonies.

+
+
+
+
+
+ +
+
+

Here is a modified function with the requested features. Note when +calculating the Otsu threshold we don’t include the very bright pixels +outside the dish.

+
+

PYTHON +

+
def count_colonies_enhanced(image_filename, sigma=1.0, min_colony_size=10, connectivity=2):
+    
+    bacteria_image = iio.imread(image_filename)
+    gray_bacteria = ski.color.rgb2gray(bacteria_image)
+    blurred_image = ski.filters.gaussian(gray_bacteria, sigma=sigma)
+    
+    # create mask excluding the very bright pixels outside the dish
+    # we dont want to include these when calculating the automated threshold
+    mask = blurred_image < 0.90
+    # calculate an automated threshold value within the dish using the Otsu method
+    t = ski.filters.threshold_otsu(blurred_image[mask])
+    # update mask to select pixels both within the dish and less than t
+    mask = np.logical_and(mask, blurred_image < t)
+    # remove objects smaller than specified area
+    mask = ski.morphology.remove_small_objects(mask, min_size=min_colony_size)
+    
+    labeled_image, count = ski.measure.label(mask, return_num=True)
+    print(f"There are {count} colonies in {image_filename}")
+    colored_label_image = ski.color.label2rgb(labeled_image, bg_label=0)
+    summary_image = ski.color.gray2rgb(gray_bacteria)
+    summary_image[mask] = colored_label_image[mask]
+    fig, ax = plt.subplots()
+    ax.imshow(summary_image)
+
+
+
+
+
+
+
+ +
+Key Points +
+
+
    +
  • Using thresholding, connected component analysis and other tools we +can automatically segment images of bacterial colonies.
  • +
  • These methods are useful for many scientific problems, especially +those involving morphometrics.
  • +
+
+
+
+
+
+ +
+Discussion +
+

Where to go from here?

+
+

Take a look at our curated list of +resources for further publicly available courses, resources and +scientific literature around image processing and more.

+
+
+
+
+
+
+
+ + +
+ + +
+ + + + + diff --git a/instructor/discuss.html b/instructor/discuss.html new file mode 100644 index 000000000..19605c924 --- /dev/null +++ b/instructor/discuss.html @@ -0,0 +1,441 @@ + +Image Processing with Python: Discussion +
+
+ + + + + +
+
+

Discussion

+

Last updated on 2023-07-26 | + + Edit this page

+ + + + + +
+ +
+ + + +

Choice of Image Processing Library

+

This lesson was originally designed to use OpenCV and the opencv-python +library (see +the last version of the lesson repository to use OpenCV).

+

In 2019-2020 the lesson was adapted to use scikit-image, as this library has +proven easier to install and enjoys more extensive documentation and +support.

+

Choice of Image Viewer

+

When the lesson was first adapted to use sckikit-image (see above), +skimage.viewer.ImageViewer was used to inspect images. This +viewer is deprecated and the lesson maintainers chose to leverage +matplotlib.pyplot.imshow with the pan/zoom and +mouse-location tools built into the Matplotlib +GUI. The ipympl +package is required to enable the interactive features of Matplotlib +in Jupyter notebooks and in Jupyter Lab. This package is included in the +setup instructions, and the backend can be enabled using the +%matplotlib widget magic.

+

The maintainers discussed the possibility of using napari as an image viewer in the lesson, +acknowledging its growing popularity and some of the advantages it holds +over the Matplotlib-based approach, especially for working with image +data in more than two dimensions. However, at the time of discussion, +napari was still in an alpha state of development, and could not be +relied on for easy and error-free installation on all operating systems, +which makes it less well-suited to use in an official Data Carpentry +curriculum.

+

The lesson Maintainers and/or Curriculum Advisory Committee (when it +exists) will monitor the progress of napari and other image viewers, and +may opt to adopt a new platform in future.

+
+
+ + +
+
+ + + diff --git a/instructor/edge-detection.html b/instructor/edge-detection.html new file mode 100644 index 000000000..2c01ffad7 --- /dev/null +++ b/instructor/edge-detection.html @@ -0,0 +1,862 @@ + +Image Processing with Python: Extra Episode: Edge Detection +
+
+ + + + + +
+
+

Extra Episode: Edge Detection

+

Last updated on 2026-03-20 | + + Edit this page

+ + + +

Estimated time: 0 minutes

+ +
+ +
+ + + +
+

Overview

+
+
+
+
+

Questions

+
  • How can we automatically detect the edges of the objects in an +image?
  • +
+
+
+
+
+
+

Objectives

+
  • Apply Canny edge detection to an image.
  • +
  • Explain how we can use sliders to expedite finding appropriate +parameter values for our scikit-image function calls.
  • +
  • Create scikit-image windows with sliders and associated callback +functions.
  • +
+
+
+
+
+

In this episode, we will learn how to use scikit-image functions to +apply edge detection to an image. In edge detection, we find +the boundaries or edges of objects in an image, by determining where the +brightness of the image changes dramatically. Edge detection can be used +to extract the structure of objects in an image. If we are interested in +the number, size, shape, or relative location of objects in an image, +edge detection allows us to focus on the parts of the image most +helpful, while ignoring parts of the image that will not help us.

+

For example, once we have found the edges of the objects in the image +(or once we have converted the image to binary using thresholding), we +can use that information to find the image contours, which we +will learn about in the +Connected Component Analysis episode. With the contours, we +can do things like counting the number of objects in the image, measure +the size of the objects, classify the shapes of the objects, and so +on.

+

As was the case for blurring and thresholding, there are several +different methods in scikit-image that can be used for edge detection, +so we will examine only one in detail.

+

Introduction to edge detection

+

To begin our introduction to edge detection, let us look at an image +with a very simple edge - this grayscale image of two overlapped pieces +of paper, one black and and one white:

+
Black and white image

The obvious edge in the image is the vertical line between the black +paper and the white paper. To our eyes, there is a quite sudden change +between the black pixels and the white pixels. But, at a pixel-by-pixel +level, is the transition really that sudden?

+

If we zoom in on the edge more closely, as in this image, we can see +that the edge between the black and white areas of the image is not a +clear-cut line.

+
Black and white edge pixels

We can learn more about the edge by examining the colour values of +some of the pixels. Imagine a short line segment, halfway down the image +and straddling the edge between the black and white paper. This plot +shows the pixel values (between 0 and 255, since this is a grayscale +image) for forty pixels spanning the transition from black to white.

+
Gradient near transition

It is obvious that the “edge” here is not so sudden! So, any +scikit-image method to detect edges in an image must be able to decide +where the edge is, and place appropriately-coloured pixels in that +location.

+

Canny edge detection

+

Our edge detection method in this workshop is Canny edge +detection, created by John Canny in 1986. This method uses a series +of steps, some incorporating other types of edge detection. The +skimage.feature.canny() function performs the following +steps:

+
  1. A Gaussian blur (that is characterised by the sigma +parameter, see Blurring Images +is applied to remove noise from the image. (So if we are doing edge +detection via this function, we should not perform our own blurring +step.)
  2. +
  3. Sobel edge detection is performed on both the cx and ry dimensions, +to find the intensity gradients of the edges in the image. Sobel edge +detection computes the derivative of a curve fitting the gradient +between light and dark areas in an image, and then finds the peak of the +derivative, which is interpreted as the location of an edge pixel.
  4. +
  5. Pixels that would be highlighted, but seem too far from any edge, +are removed. This is called non-maximum suppression, and the +result is edge lines that are thinner than those produced by other +methods.
  6. +
  7. A double threshold is applied to determine potential edges. Here +extraneous pixels caused by noise or milder colour variation than +desired are eliminated. If a pixel’s gradient value - based on the Sobel +differential - is above the high threshold value, it is considered a +strong candidate for an edge. If the gradient is below the low threshold +value, it is turned off. If the gradient is in between, the pixel is +considered a weak candidate for an edge pixel.
  8. +
  9. Final detection of edges is performed using hysteresis. +Here, weak candidate pixels are examined, and if they are connected to +strong candidate pixels, they are considered to be edge pixels; the +remaining, non-connected weak candidates are turned off.
  10. +

For a user of the skimage.feature.canny() edge detection +function, there are three important parameters to pass in: +sigma for the Gaussian filter in step one and the low and +high threshold values used in step four of the process. These values +generally are determined empirically, based on the contents of the +image(s) to be processed.

+

The following program illustrates how the +skimage.feature.canny() method can be used to detect the +edges in an image. We will execute the program on the +data/shapes-01.jpg image, which we used before in the Thresholding episode:

+
coloured shapes

We are interested in finding the edges of the shapes in the image, +and so the colours are not important. Our strategy will be to read the +image as grayscale, and then apply Canny edge detection. Note that when +reading the image with iio.imread(..., mode="L") the image +is converted to a grayscale image of same dtype.

+

This program takes three command-line arguments: the filename of the +image to process, and then two arguments related to the double +thresholding in step four of the Canny edge detection process. These are +the low and high threshold values for that step. After the required +libraries are imported, the program reads the command-line arguments and +saves them in their respective variables.

+
+

PYTHON +

+
"""Python script to demonstrate Canny edge detection.
+
+usage: python CannyEdge.py <filename> <sigma> <low_threshold> <high_threshold>
+"""
+import imageio.v3 as iio
+import matplotlib.pyplot as plt
+import skimage.feature
+import sys
+
+# read command-line arguments
+filename = sys.argv[1]
+sigma = float(sys.argv[2])
+low_threshold = float(sys.argv[3])
+high_threshold = float(sys.argv[4])
+
+

Next, the original images is read, in grayscale, and displayed.

+
+

PYTHON +

+
# load and display original image as grayscale
+image = iio.imread(uri=filename, mode="L")
+plt.imshow(image)
+
+

Then, we apply Canny edge detection with this function call:

+
+

PYTHON +

+
edges = skimage.feature.canny(
+    image=image,
+    sigma=sigma,
+    low_threshold=low_threshold,
+    high_threshold=high_threshold,
+)
+
+

As we are using it here, the skimage.feature.canny() +function takes four parameters. The first parameter is the input image. +The sigma parameter determines the amount of Gaussian +smoothing that is applied to the image. The next two parameters are the +low and high threshold values for the fourth step of the process.

+

The result of this call is a binary image. In the image, the edges +detected by the process are white, while everything else is black.

+

Finally, the program displays the edges image, showing +the edges that were found in the original.

+
+

PYTHON +

+
# display edges
+skimage.io.imshow(edges)
+
+

Here is the result, for the coloured shape image above, with sigma +value 2.0, low threshold value 0.1 and high threshold value 0.3:

+
Output file of Canny edge detection

Note that the edge output shown in a scikit-image window may look +significantly worse than the image would look if it were saved to a file +due to resampling artefacts in the interactive image viewer. The image +above is the edges of the junk image, saved in a PNG file. Here is how +the same image looks when displayed in a scikit-image output window:

+
Output window of Canny edge detection

Interacting with the image viewer using viewer plugins

+

As we have seen, for a user of the +skimage.feature.canny() edge detection function, three +important parameters to pass in are sigma, and the low and high +threshold values used in step four of the process. These values +generally are determined empirically, based on the contents of the +image(s) to be processed.

+

Here is an image of some glass beads that we can use as input into a +Canny edge detection program:

+
Beads image

We could use the code/edge-detection/CannyEdge.py +program above to find edges in this image. To find acceptable values for +the thresholds, we would have to run the program over and over again, +trying different threshold values and examining the resulting image, +until we find a combination of parameters that works best for the +image.

+

Or, we can write a Python program and create a viewer plugin +that uses scikit-image sliders, that allow us to vary the +function parameters while the program is running. In other words, we can +write a program that presents us with a window like this:

+
Canny UI

Then, when we run the program, we can use the sliders to vary the +values of the sigma and threshold parameters until we are satisfied with +the results. After we have determined suitable values, we can use the +simpler program to utilise the parameters without bothering with the +user interface and sliders.

+

Here is a Python program that shows how to apply Canny edge +detection, and how to add sliders to the user interface. There are four +parts to this program, making it a bit (but only a bit) more +complicated than the programs we have looked at so far. The added +complexity comes from setting up the sliders for the parameters that +were previously read from the command line: In particular, we have +added

+
  • The canny() filter function that returns an edge +image,
  • +
  • The cannyPlugin plugin object, to which we add
  • +
  • The sliders for sigma, and low and high +threshold values, and
  • +
  • The main program, i.e., the code that is executed when the program +runs.
  • +

We will look at the main program part first, and then return to +writing the plugin. The first several lines of the main program are +easily recognizable at this point: saving the command-line argument, +reading the image in grayscale, and creating a window.

+
+

PYTHON +

+
"""Python script to demonstrate Canny edge detection with sliders to adjust the thresholds.
+
+usage: python CannyTrack.py <filename>
+"""
+import imageio.v3 as iio
+import matplotlib.pyplot as plt
+import skimage.feature
+import skimage.viewer
+import sys
+
+
+filename = sys.argv[1]
+image = iio.imread(uri=filename, mode="L")
+viewer = plt.imshow(image)
+
+

The skimage.viewer.plugins.Plugin class is designed to +manipulate images. It takes an image_filter argument in the +constructor that should be a function. This function should produce a +new image as an output, given an image as the first argument, which then +will be automatically displayed in the image viewer.

+
+

PYTHON +

+
# Create the plugin and give it a name
+canny_plugin = skimage.viewer.plugins.Plugin(image_filter=skimage.feature.canny)
+canny_plugin.name = "Canny Filter Plugin"
+
+

We want to interactively modify the parameters of the filter function +interactively. scikit-image allows us to further enrich the plugin by +adding widgets, like skimage.viewer.widgets.Slider, +skimage.viewer.widgets.CheckBox, +skimage.viewer.widgets.ComboBox. Whenever a widget +belonging to the plugin is updated, the filter function is called with +the updated parameters. This function is also called a callback +function. The following code adds sliders for sigma, +low_threshold and high_thresholds.

+
+

PYTHON +

+
# Add sliders for the parameters
+canny_plugin += skimage.viewer.widgets.Slider(
+    name="sigma", low=0.0, high=7.0, value=2.0
+)
+canny_plugin += skimage.viewer.widgets.Slider(
+    name="low_threshold", low=0.0, high=1.0, value=0.1
+)
+canny_plugin += skimage.viewer.widgets.Slider(
+    name="high_threshold", low=0.0, high=1.0, value=0.2
+)
+
+

A slider is a widget that lets you choose a number by dragging a +handle along a line. On the left side of the line, we have the lowest +value, on the right side the highest value that can be chosen. The range +of values in between is distributed equally along this line. All three +sliders are constructed in the same way: The first argument is the name +of the parameter that is tweaked by the slider. With the arguments +low, and high, we supply the limits for the +range of numbers that is represented by the slider. The +value argument specifies the initial value of that +parameter, so where the handle is located when the plugin is started. +Adding the slider to the plugin makes the values available as parameters +to the filter_function.

+
+
+ +
+Callout +
+

How does the plugin know how to call the +filter function with the parameters?

+
+

The filter function will be called with the slider parameters +according to their names as keyword arguments. So it +is very important to name the sliders appropriately.

+
+
+
+

Finally, we add the plugin the viewer and display the resulting user +interface:

+
+

PYTHON +

+
# add the plugin to the viewer and show the window
+viewer += canny_plugin
+viewer.show()
+
+

Here is the result of running the preceding program on the beads +image, with a sigma value 1.0, low threshold value 0.1 and high +threshold value 0.3. The image shows the edges in an output file.

+
Beads edges (file)
+
+ +
+Challenge +
+

Applying Canny edge detection to another image +(5 min)

+
+

Now, run the program above on the image of coloured shapes, +data/shapes-01.jpg. Use a sigma of 1.0 and adjust low and +high threshold sliders to produce an edge image that looks like +this:

+
coloured shape edges

What values for the low and high threshold values did you use to +produce an image similar to the one above?

+
+
+
+
+
+ +
+
+

The coloured shape edge image above was produced with a low threshold +value of 0.05 and a high threshold value of 0.07. You may be able to +achieve similar results with other threshold values.

+
+
+
+
+
+
+ +
+Challenge +
+

Using sliders for thresholding (30 min)

+
+

Now, let us apply what we know about creating sliders to another, +similar situation. Consider this image of a collection of maize +seedlings, and suppose we wish to use simple fixed-level thresholding to +mask out everything that is not part of one of the plants.

+
Maize roots image

To perform the thresholding, we could first create a histogram, then +examine it, and select an appropriate threshold value. Here, however, +let us create an application with a slider to set the threshold value. +Create a program that reads in the image, displays it in a window with a +slider, and allows the slider value to vary the threshold value used. +You will find the image at +data/maize-roots-grayscale.jpg.

+
+
+
+
+
+ +
+
+

Here is a program that uses a slider to vary the threshold value used +in a simple, fixed-level thresholding process.

+
+

PYTHON +

+
"""Python program to use a slider to control fixed-level thresholding value.
+
+usage: python interactive_thresholding.py <filename>
+"""
+
+import imageio.v3 as iio
+import skimage
+import skimage.viewer
+import sys
+
+filename = sys.argv[1]
+
+
+def filter_function(image, sigma, threshold):
+    masked = image.copy()
+    masked[skimage.filters.gaussian(image, sigma=sigma) <= threshold] = 0
+    return masked
+
+
+smooth_threshold_plugin = skimage.viewer.plugins.Plugin(
+    image_filter=filter_function
+)
+
+smooth_threshold_plugin.name = "Smooth and Threshold Plugin"
+
+smooth_threshold_plugin += skimage.viewer.widgets.Slider(
+    "sigma", low=0.0, high=7.0, value=1.0
+)
+smooth_threshold_plugin += skimage.viewer.widgets.Slider(
+    "threshold", low=0.0, high=1.0, value=0.5
+)
+
+image = iio.imread(uri=filename, mode="L")
+
+viewer = skimage.viewer.ImageViewer(image=image)
+viewer += smooth_threshold_plugin
+viewer.show()
+
+

Here is the output of the program, blurring with a sigma of 1.5 and a +threshold value of 0.45:

+
Thresholded maize roots
+
+
+
+

Keep this plugin technique in your image processing “toolbox.” You +can use sliders (or other interactive elements, see the +scikit-image documentation) to vary other kinds of parameters, such +as sigma for blurring, binary thresholding values, and so on. A few +minutes developing a program to tweak parameters like this can save you +the hassle of repeatedly running a program from the command line with +different parameter values. Furthermore, scikit-image already comes with +a few viewer plugins that you can check out in the +documentation.

+

Other edge detection functions

+

As with blurring, there are other options for finding edges in +skimage. These include skimage.filters.sobel(), which you +will recognise as part of the Canny method. Another choice is +skimage.filters.laplace().

+
+
+ +
+Key Points +
+
+
  • The skimage.viewer.ImageViewer is extended using a +skimage.viewer.plugins.Plugin.
  • +
  • We supply a filter function callback when creating a Plugin.
  • +
  • Parameters of the callback function are manipulated interactively by +creating sliders with the skimage.viewer.widgets.slider() +function and adding them to the plugin.
  • +
+
+
+
+
+ + +
+
+ + + diff --git a/instructor/further-reading.html b/instructor/further-reading.html new file mode 100644 index 000000000..598cb9f84 --- /dev/null +++ b/instructor/further-reading.html @@ -0,0 +1,446 @@ + +Image Processing with Python: Further Reading +
+
+ + + + + + + + +
+
+ + + diff --git a/instructor/images.html b/instructor/images.html new file mode 100644 index 000000000..874c285d2 --- /dev/null +++ b/instructor/images.html @@ -0,0 +1,801 @@ + + + + + +Image Processing with Python: All Images + + + + + + + + + + + + +
+
+ + + + + + +
+
+

All Images

+ +

Introduction

+
+

Figure 1

+ +
Bacteria colony

+

Figure 2

+ +
Colonies counted

+

Figure 3

+ +
Bacteria colony

Image Basics

+
+

Figure 1

+ +
Original size image

+

Figure 2

+ +
Enlarged image area

+

Figure 3

+ +
Image of 8

+

Figure 4

+ +
Image of 0

+

Figure 5

+ +
Cartesian coordinate system

+

Figure 6

+ +
Image coordinate system

+

Figure 7

+ +
Left-hand coordinate system

+

Figure 8

+ +
Image of 5

+

Figure 9

+ +
Image of three colours

+

Figure 10

+ +
Image in greyscale

+

Figure 11

+ +
Image of checkerboard

+

Figure 12

+ +
Image of red channel

+

Figure 13

+ +
Image of green channel

+

Figure 14

+ +
Image of blue channel

+

Figure 15

+ +
RGB colour table

+

Figure 16

+ +
Original image

+

Figure 17

+ +
Enlarged, uncompressed

+

Figure 18

+ +
Enlarged, compressed

+

Figure 19

+ +
Uncompressed histogram

Working with scikit-image

+
+

Figure 1

+ +
Root cluster image

+

Figure 2

+ +
Thresholded root image

+

Figure 3

+ +
Su-Do-Ku puzzle

+

Figure 4

+ +
Modified Su-Do-Ku puzzle

+

Figure 5

+ +
Whiteboard image

+

Figure 6

+ +
Whiteboard coordinates

+

Figure 7

+ +
"Erased" whiteboard

Drawing and Bitwise Operations

+
+

Figure 1

+ +
Maize seedlings

+

Figure 2

+ +

Here is what our constructed mask looks like: Maize image mask

+
+

Figure 3

+ +
Sample shapes

+

Figure 4

+ +
Applied mask

+

Figure 5

+ +
Remote control image

+

Figure 6

+ +
Remote control masked

+

Figure 7

+ +
96-well plate

+

Figure 8

+ +
Masked 96-well plate

Creating Histograms

+
+

Figure 1

+ +

We will start with grayscale images, and then move on to colour +images. We will use this image of a plant seedling as an example: Plant seedling

+
+

Figure 2

+ +
Plant seedling

+

Figure 3

+ +
Plant seedling histogram

+

Figure 4

+ +
Grayscale histogram of masked area

+

Figure 5

+ +
Colour histogram

+

Figure 6

+ +
Well plate image

+

Figure 7

+ +
Masked well plate

+

Figure 8

+ +
Well plate histogram

Blurring Images

+
+

Figure 1

+ +
Cat image

+

Figure 2

+ +
Cat eye pixels

+

Figure 3

+ +

A Gaussian function maps random variables into a normal distribution +or “Bell Curve”. Gaussian function

+
+

Figure 4

+ +
2D Gaussian function

+

Figure 5

+ +
2D Gaussian function

+

Figure 6

+ +
Image corner pixels

+

Figure 7

+ +
Image multiplication

+

Figure 8

+ +
Blur demo animation

+

Figure 9

+ +
Original image

+

Figure 10

+ +
Blurred image

+

Figure 11

+ +
Bacteria colony
Graysacle version of the Petri dish image
+

+

Figure 12

+ +
Bacteria colony image with selected pixels marker
Grayscale Petri dish image marking selected +pixels for profiling
+

+

Figure 13

+ +
Pixel intensities profile in original image
Intensities profile line plot of pixels along +Y=150 in original image
+

+

Figure 14

+ +
Pixel intensities profile in blurred image
Intensities profile of pixels along Y=150 in +blurred image
+

+

Figure 15

+ +
3D surface plot showing pixel intensities across the whole example Petri dish image before blurring
A 3D plot of pixel intensities across the whole +Petri dish image before blurring. Explore +how this plot was created with matplotlib. Image credit: Carlos H Brandt.
+

+

Figure 16

+ +
3D surface plot illustrating the smoothing effect on pixel intensities across the whole example Petri dish image after blurring
A 3D plot of pixel intensities after Gaussian +blurring of the Petri dish image. Note the ‘smoothing’ effect on the +pixel intensities of the colonies in the image, and the ‘flattening’ of +the background noise at relatively low pixel intensities throughout the +image. Explore +how this plot was created with matplotlib. Image credit: Carlos H Brandt.
+

+

Figure 17

+ +
Rectangular kernel blurred image

Thresholding

+
+

Figure 1

+ +
Image with geometric shapes on white background

+

Figure 2

+ +
Grayscale image of the geometric shapes

+

Figure 3

+ +
Grayscale histogram of the geometric shapes image

+

Figure 4

+ +
Binary mask of the geometric shapes created by thresholding

+

Figure 5

+ +
Selected shapes after applying binary mask

+

Figure 6

+ +
Another image with geometric shapes on white background

+

Figure 7

+ +
Grayscale histogram of the second geometric shapes image

+

Figure 8

+ +
Binary mask created by thresholding the second geometric shapes image

+

Figure 9

+ +
Selected shapes after applying binary mask to the second geometric shapes image

+

Figure 10

+ +
Image of a maize root

+

Figure 11

+ +
Grayscale histogram of the maize root image

+

Figure 12

+ +
Binary mask of the maize root system

+

Figure 13

+ +
Masked selection of the maize root system

+

Figure 14

+ +
Four images of maize roots

+

Figure 15

+ +
Binary masks of the four maize root images

+

Figure 16

+ +
Improved binary masks of the four maize root images

+

Figure 17

+ +
Image of bacteria colonies in a petri dish

+

Figure 18

+ +
Grayscale histogram of the bacteria colonies image

+

Figure 19

+ +
Binary mask of the bacteria colonies image

Connected Component Analysis

+
+

Figure 1

+ +
Original shapes image

+

Figure 2

+ +
Mask created by thresholding

+

Figure 3

+ +
Labeled objects

+

Figure 4

+ +
shapes-01.jpg mask detail

+

Figure 5

+ +
Histogram of object areas

+

Figure 6

+ +
Objects filtered by area

+

Figure 7

+ +
Objects colored by area

Capstone Challenge

+
+

Figure 1

+ +
Colony image 1

+

Figure 2

+ +
Colony image 2

+

Figure 3

+ +
Colony image 3

+

Figure 4

+ +
Sample morphometric output

+

Figure 5

+ +
Colony image 1

+

Figure 6

+ +
Gray Colonies

+

Figure 7

+ +
Histogram image

+

Figure 8

+ +
Colony mask image

+

Figure 9

+ +
Sample morphometric output

+

Figure 10

+ + + +

Colony 1 outputColony 2 outputColony 3 output

+
+
+
+
+ + +
+ + +
+ + + + + diff --git a/instructor/index.html b/instructor/index.html new file mode 100644 index 000000000..d684df1a3 --- /dev/null +++ b/instructor/index.html @@ -0,0 +1,693 @@ + +Image Processing with Python: Summary and Schedule +
+
+ + + + + +
+

Summary and Schedule

+ + +

This lesson shows how to use Python and scikit-image to do basic +image processing.

+
+
+ +
+Prerequisite +
+

Prerequisites

+
+

This lesson assumes you have a working knowledge of Python and some +previous exposure to the Bash shell. These requirements can be fulfilled +by: a) completing a Software Carpentry Python workshop +or b) completing a Data Carpentry Ecology workshop +(with Python) and a Data Carpentry Genomics workshop +or c) independent exposure to both Python and the Bash +shell.

+

If you’re unsure whether you have enough experience to participate in +this workshop, please read over this detailed +list, which gives all of the functions, operators, and other +concepts you will need to be familiar with.

+
+
+
+

Before following the lesson, please make sure +you have the software and data required.

+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

+ The actual schedule may vary slightly depending on the topics and exercises chosen by the instructor. +

+

Before joining the workshop or following the lesson, please complete +the data and software setup described in this page.

+

Data

+

The example images and a description of the Python environment used +in this lesson are available on FigShare. To download the data, please +visit the +dataset page for this workshop and click the “Download all” button. +Unzip the downloaded file, and save the contents as a folder called +data somewhere you will easily find it again, e.g. your +Desktop or a folder you have created for using in this workshop. (The +name data is optional but recommended, as this is the name +we will use to refer to the folder throughout the lesson.)

+

Software

+
  1. Download and install the latest Miniforge distribution of +Python for your operating system. (See +more detailed instructions from The Carpentries.) If you already +have a Python 3 setup that you are happy with, you can continue to use +that (we recommend that you make sure your Python version is current). +The next step assumes that conda is available to manage +your Python environment.

  2. +
  3. +

    Set up an environment to work in during the lesson. In a terminal +(Linux/Mac) or the MiniForge Prompt application (Windows), navigate to +the location where you saved the unzipped data for the lesson and run +the following command:

    +
    +

    BASH +

    +
    conda env create -f environment.yml
    +
    +

    If prompted, allow conda to install the required +libraries.

    +
  4. +
  5. +

    Activate the new environment you just created:

    +
    +

    BASH +

    +
    conda activate dc-image
    +
    +
    +
    + +
    +Callout +
    +

    Enabling the ipympl backend in +Jupyter notebooks

    +
    +

    The ipympl backend can be enabled with the +%matplotlib Jupyter magic. Put the following command in a +cell in your notebooks (e.g., at the top) and execute the cell before +any plotting commands.

    +
    +

    PYTHON +

    +
    %matplotlib widget
    +
    +
    +
    +
    +
    +
    + +
    +Callout +
    +

    Older +JupyterLab versions

    +
    +

    If you are using an older version of JupyterLab, you may also need to +install the labextensions manually, as explained in the README file for +the ipympl package.

    +
    +
    +
    +
  6. +
  7. +

    Open a Jupyter notebook:

    +
    +
    + +
    +
    +

    Open a terminal and type jupyter lab.

    +
    +
    +
    +
    +
    +
    + +
    +
    +

    Launch the Miniforge Prompt program and type +jupyter lab. (Running this command on the standard Command +Prompt will return an error: +'jupyter' is not recognized as an internal or external command, operable program or batch file.)

    +
    +
    +
    +
    +

    After Jupyter Lab has launched, click the “Python 3” button under +“Notebook” in the launcher window, or use the “File” menu, to open a new +Python 3 notebook.

    +
  8. +
  9. +

    To test your environment, run the following lines in a cell of +the notebook:

    +
    +

    PYTHON +

    +
    import imageio.v3 as iio
    +import matplotlib.pyplot as plt
    +import skimage as ski
    +
    +%matplotlib widget
    +
    +# load an image
    +image = iio.imread(uri='data/colonies-01.tif')
    +
    +# rotate it by 45 degrees
    +rotated = ski.transform.rotate(image=image, angle=45)
    +
    +# display the original image and its rotated version side by side
    +fig, ax = plt.subplots(1, 2)
    +ax[0].imshow(image)
    +ax[1].imshow(rotated)
    +
    +

    Upon execution of the cell, a figure with two images should be +displayed in an interactive widget. When hovering over the images with +the mouse pointer, the pixel coordinates and colour values are displayed +below the image.

    +
    +
    + +
    +
    +

    Overview of the Jupyter Notebook graphical user interface To +run Python code in a Jupyter notebook cell, click on a cell in the +notebook (or add a new one by clicking the + button in the +toolbar), make sure that the cell type is set to “Code” (check the +dropdown in the toolbar), and add the Python code in that cell. After +you have added the code, you can run the cell by selecting “Run” -> +“Run selected cell” in the top menu, or pressing +Shift+Enter.

    +
    +
    +
    +
    +
  10. +
  11. A small number of exercises will require you to run commands in a +terminal. Windows users should use PowerShell for this. PowerShell is +probably installed by default but if not you should download +and install it.

  12. +
+ + +
+
+ + + diff --git a/instructor/instructor-notes.html b/instructor/instructor-notes.html new file mode 100644 index 000000000..b54e58dec --- /dev/null +++ b/instructor/instructor-notes.html @@ -0,0 +1,650 @@ + + + + + +Image Processing with Python: Instructor Notes + + + + + + + + + + + + +
+
+ + + + + + +
+
+

Instructor Notes

+ + +

Estimated Timings +

+
+

This is a relatively new curriculum. The estimated timings for each +episode are based on limited experience and should be taken as a rough +guide only. If you teach the curriculum, the Maintainers would be +delighted to receive feedback with information about the time that was +required for teaching and exercises in each episode of your +workshop.

+

Please open +an issue on the repository to share your experience with the lesson +Maintainers.

+

Working with Jupyter notebooks +

+
+
    +
  • This lesson is designed to be taught using Jupyter notebooks. We +recommend that instructors guide learners to create a new Jupyter +notebook for each episode.

  • +
  • Python import statements typically appear in the +first code block near the top of each episode. In some cases, the +purpose of specific libraries is briefly explained as part of the +exercises.

  • +
  • The possibility of executing the code cells in a notebook in +arbitrary order can cause confusion. Using the “restart kernel and run +all cells” feature is one way to accomplish linear execution of the +notebook and may help locate and identify coding issues.

  • +
  • Many episodes in this lesson load image files from disk. To avoid +name clashes in episodes that load multiple image files, we have used +unique variable names (instead of generic names such as +image or img). When copying code snippets +between exercises, the variable names may have to be changed. The +maintainers are keen to receive feedback on whether this convention +proves practical in workshops.

  • +

Working with imageio and skimage +

+
+
    +
  • imageio.v3 allows to load images in different modes +by passing the mode= argument to imread(). +Depending on the image file and mode, the dtype of the +resulting Numpy array can be different (e.g., +dtype('uint8') or dtype('float64'). In the +lesson, skimage.util.img_as_ubyte() and +skimage.util.img_as_float() are used to convert the data +type when necessary.

  • +
  • Some skimage functions implicitly convert the pixel +values to floating-point numbers. Several callout boxes have been added +throughout the lesson to raise awareness, but this may still prompt +questions from learners.

  • +
  • In certain situations, imread() returns a read-only +array. This depends on the image file type and on the backend (e.g., +Pillow). If a read-only error is encountered, +image = np.array(image) can be used to create a writable +copy of the array before manipulating its pixel values.

  • +
  • Be aware that learners might get surprising results in the +Keeping only low intensity pixels exercise, if +plt.imshow is called without the vmax +parameter. A detailed explanation is given in the Plotting single +channel images (cmap, vmin, vmax) callout box.

  • +

Additional resources +

+
+

Questions from Learners +

+
+
+

Q: Where would I find out that coordinates are x,y not +r,c? +

+

A: In an image viewer, hover your cursor over top-left (origin) the +move down and see which number increases.

+
+
+

Q: Why does saving the image take such a long time? +(skimage-images/saving images PNG example) +

+

A: It is a large image.

+
+
+

Q: Are the coordinates represented x,y or +r,c in the code (e.g. in array.shape)? +

+

A: Always r,c with numpy arrays, unless clearly +specified otherwise - only represented x,y when image is +displayed by a viewer. Take home is don’t rely on it - always check!

+
+
+

Q: What if I want to increase size? How does skimage +upsample? (image resizing) +

+

A: When resizing or rescaling an image, skimage performs +interpolation to up-size or down-size the image. Technically, this is +done by fitting a spline +function to the image data. The spline function is based on the +intensity values in the original image and can be used to approximate +the intensity at any given coordinate in the resized/rescaled image. +Note that the intensity values in the new image are an approximation of +the original values but should not be treated as the actual, observed +data. skimage.transform.resize has a number of optional +parameters that allow the user to control, e.g., the order of the spline +interpolation. The scikit-image +documentation provides additional information on other +parameters.

+
+
+

Q: Why are some lines missing from the sudoku image when it is +displayed inline in a Jupyter Notebook? (skimage-images/low intensity +pixels exercise) +

+

A: They are actually present in image but not shown due to +interpolation.

+
+
+

Q: Does blurring take values of pixels already blurred, or is +blurring done on original pixel values only? +

+

A: Blurring is done on original pixel values only.

+
+
+

Q: Can you blur while retaining edges? +

+

A: Yes, many different filters/kernels exist, some of which are +designed to be edge-preserving.

+
+

Troubleshooting +

+
+

Learners reported a problem on some operating systems, that +Shift+Enter is prevented from running a cell in +Jupyter when the caps lock key is active.

+

Introduction

+

Image Basics

+

Working with scikit-image

+

Drawing and Bitwise Operations

+

Creating Histograms

+

Blurring Images

+
+

+Terminology about image boundaries +

+

Take care to avoid mixing up the term “edge” to describe the edges of +objects within an image and the outer boundaries of the images +themselves. Lack of a clear distinction here may be confusing for +learners.

+
+

Thresholding

+
+

+Instructor Note +

+

The histogram of the maize root image may prompt questions from +learners about the interpretation of the peaks and the broader region +around 0.6. The focus here is on the separation of background and +foreground pixel values. We note that Otsu’s method does not work well +for the image with the shapes used earlier in this episode, as the +foreground pixel values are more distributed. These examples could be +augmented with a discussion of unimodal, bimodal, and multimodal +histograms. While these points can lead to fruitful considerations, the +text in this episode attempts to reduce cognitive load and deliberately +simplifies the discussion.

+
+

Connected Component Analysis

+

Capstone Challenge

+
+
+
+
+ + +
+ + +
+ + + + + diff --git a/instructor/key-points.html b/instructor/key-points.html new file mode 100644 index 000000000..215e645f5 --- /dev/null +++ b/instructor/key-points.html @@ -0,0 +1,598 @@ + + + + + +Image Processing with Python: Key Points + + + + + + + + + + + + +
+
+ + + + + + +
+
+

Key Points

+ +

Introduction

+
+
    +
  • Simple Python and scikit-image techniques can be used to solve +genuine image analysis problems.
  • +
  • Morphometric problems involve the number, shape, and / or size of +the objects in an image.
  • +

Image Basics

+
+
    +
  • Digital images are represented as rectangular arrays of square +pixels.
  • +
  • Digital images use a left-hand coordinate system, with the origin in +the upper left corner, the x-axis running to the right, and the y-axis +running down. Some learners may prefer to think in terms of counting +down rows for the y-axis and across columns for the x-axis. Thus, we +will make an effort to allow for both approaches in our lesson +presentation.
  • +
  • Most frequently, digital images use an additive RGB model, with +eight bits for the red, green, and blue channels.
  • +
  • scikit-image images are stored as multi-dimensional NumPy +arrays.
  • +
  • In scikit-image images, the red channel is specified first, then the +green, then the blue, i.e., RGB.
  • +
  • Lossless compression retains all the details in an image, but lossy +compression results in loss of some of the original image detail.
  • +
  • BMP images are uncompressed, meaning they have high quality but also +that their file sizes are large.
  • +
  • JPEG images use lossy compression, meaning that their file sizes are +smaller, but image quality may suffer.
  • +
  • TIFF images can be uncompressed or compressed with lossy or lossless +compression.
  • +
  • Depending on the camera or sensor, various useful pieces of +information may be stored in an image file, in the image metadata.
  • +

Working with scikit-image

+
+
    +
  • Images are read from disk with the iio.imread() +function.
  • +
  • We create a window that automatically scales the displayed image +with Matplotlib and calling imshow() on the global figure +object.
  • +
  • Colour images can be transformed to grayscale using +ski.color.rgb2gray() or, in many cases, be read as +grayscale directly by passing the argument mode="L" to +iio.imread().
  • +
  • We can resize images with the ski.transform.resize() +function.
  • +
  • NumPy array commands, such as +image[image < 128] = 0, can be used to manipulate the +pixels of an image.
  • +
  • Array slicing can be used to extract sub-images or modify areas of +images, e.g., clip = image[60:150, 135:480, :].
  • +
  • Metadata is not retained when images are loaded as NumPy arrays +using iio.imread().
  • +

Drawing and Bitwise Operations

+
+
    +
  • We can use the NumPy zeros() function to create a +blank, black image.
  • +
  • We can draw on scikit-image images with functions such as +ski.draw.rectangle(), ski.draw.disk(), +ski.draw.line(), and more.
  • +
  • The drawing functions return indices to pixels that can be set +directly.
  • +

Creating Histograms

+
+
    +
  • In many cases, we can load images in grayscale by passing the +mode="L" argument to the iio.imread() +function.
  • +
  • We can create histograms of images with the +np.histogram function.
  • +
  • We can display histograms using ax.plot() with the +bin_edges and histogram values returned by +np.histogram().
  • +
  • The plot can be customised using ax.set_xlabel(), +ax.set_ylabel(), ax.set_xlim(), +ax.set_ylim(), and ax.set_title().
  • +
  • We can separate the colour channels of an RGB image using slicing +operations and create histograms for each colour channel +separately.
  • +

Blurring Images

+
+
    +
  • Applying a low-pass blurring filter smooths edges and removes noise +from an image.
  • +
  • Blurring is often used as a first step before we perform +thresholding or edge detection.
  • +
  • The Gaussian blur can be applied to an image with the +ski.filters.gaussian() function.
  • +
  • Larger sigma values may remove more noise, but they will also remove +detail from an image.
  • +

Thresholding

+
+
    +
  • Thresholding produces a binary image, where all pixels with +intensities above (or below) a threshold value are turned on, while all +other pixels are turned off.
  • +
  • The binary images produced by thresholding are held in +two-dimensional NumPy arrays, since they have only one colour value +channel. They are boolean, hence they contain the values 0 (off) and 1 +(on).
  • +
  • Thresholding can be used to create masks that select only the +interesting parts of an image, or as the first step before edge +detection or finding contours.
  • +

Connected Component Analysis

+
+
    +
  • We can use ski.measure.label to find and label +connected objects in an image.
  • +
  • We can use ski.measure.regionprops to measure +properties of labeled objects.
  • +
  • We can use ski.morphology.remove_small_objects to mask +small objects and remove artifacts from an image.
  • +
  • We can display the labeled image to view the objects coloured by +label.
  • +

Capstone Challenge

+
+
    +
  • Using thresholding, connected component analysis and other tools we +can automatically segment images of bacterial colonies.
  • +
  • These methods are useful for many scientific problems, especially +those involving morphometrics.
  • +
+
+
+
+ + +
+ + +
+ + + + + diff --git a/instructor/prereqs.html b/instructor/prereqs.html new file mode 100644 index 000000000..3d357a146 --- /dev/null +++ b/instructor/prereqs.html @@ -0,0 +1,477 @@ + +Image Processing with Python: Prerequisites +
+
+ + + + + +
+
+

Prerequisites

+

Last updated on 2023-07-26 | + + Edit this page

+ + + + + +
+ +
+ + + +

This lesson assumes you have a working knowledge of Python and some +previous exposure to the Bash shell.

+

These requirements can be fulfilled by:

+
  1. completing a Software Carpentry Python workshop +or +
  2. +
  3. completing a Data Carpentry Ecology workshop (with Python) +and a Data Carpentry Genomics workshop +or +
  4. +
  5. coursework in or independent learning of both Python and the Bash +shell.
  6. +
+

Bash shell skills

+

The skill set listed below is covered in any Software Carpentry +workshop, as well as in Data Carpentry’s Genomics workshop. These skills +can also be learned through coursework or independent learning.

+

Be able to:

+
  • Identify and navigate to your home directory.
  • +
  • Identify your current working directory.
  • +
  • Navigating directories using pwd, ls, +cd <subdirectory>, and cd .. +
  • +
  • Run a Python script from the command line.
  • +
+
+

Python skills

+

This skill set listed below is covered in both Software Carpentry’s +Python workshop and in Data Carpentry’s Ecology workshop with Python. +These skills can also be learned through coursework or independent +learning.

+

Be able to:

+
  • Use the assignment operator to create int, +float, and str variables.
  • +
  • Perform basic arithmetic operations (e.g. addition, subtraction) on +variables.
  • +
  • Convert strings to ints or floats where appropriate.
  • +
  • Create a list and alter lists by appending, inserting, +or removing values.
  • +
  • Use indexing and slicing to access elements of strings, lists, and +NumPy arrays.
  • +
  • Use good coding practices to comment your code and choose +appropriate variable names.
  • +
  • Write a for loop that increments a variable.
  • +
  • Write conditional statements using if, +elif, and else.
  • +
  • Use comparison operators (==, !=, +<, <=, >, +>=) in conditional statements.
  • +
  • Read data from a file using read(), +readline(), and readlines().
  • +
  • Open, read from, write to, and close input and output files.
  • +
  • Use print() and len() to inspect +variables.
  • +

The following skills are useful, but not required:

+
  • Apply a function to an entire NumPy array or to a single array +axis.
  • +
  • Write a user-defined function.
  • +

If you are signed up, or considering signing up for a workshop, and +aren’t sure whether you meet these reqirements, please get in touch with +the workshop instructors or host.

+
+ + + +
+
+ + +
+
+ + + diff --git a/instructor/profiles.html b/instructor/profiles.html new file mode 100644 index 000000000..e47c431ae --- /dev/null +++ b/instructor/profiles.html @@ -0,0 +1,381 @@ + +Image Processing with Python: Learner Profiles +
+
+ + + + + +
+
+

Learner Profiles

+ +

This is a placeholder file. Please add content here.

+ +
+
+ + +
+
+ + + diff --git a/instructor/reference.html b/instructor/reference.html new file mode 100644 index 000000000..a8fe09415 --- /dev/null +++ b/instructor/reference.html @@ -0,0 +1,589 @@ + +Image Processing with Python: Glossary +
+
+ + + + + +
+
+

Glossary

+

Last updated on 2026-03-23 | + + Edit this page

+ + + + + +
+ +
+ + +

Glossary

+

(Some definitions are taken from Glosario. Follow the links +from terms to see definitions in languages other than English.)

+

{:auto_ids}

+
adaptive thresholding
+
+thresholding that uses a cut-off value that varies for pixels in +different regions of the image. +
+
additive colour model
+
+a colour model that predicts the appearance of colours by summing the +numeric representations of the component colours. +
+
bacterial colony
+
+a visible cluster of bacteria growing on the surface of or within a +solid medium, presumably cultured from a single cell. +
+
binary image
+
+an image of pixels with only two possible values, 0 and 1. Typically, +the two colours used for a binary image are black and white. +
+
bit
+
+a unit of information representing alternatives, yes/no, true/false. In +computing a state of either 0 or 1. +
+
blur
+
+the averaging of pixel intensities within a neighbourhood. This has the +effect of “softening” the features of the image, reducing noise and +finer detail. +
+
BMP (bitmap image file)
+
+a raster graphics image file format used to store bitmap digital images, +independently of the display device. +
+
bounding box
+
+the smallest enclosing box for a set of points. +
+
byte
+
+a unit of digital information that typically consists of eight binary +digits, or bits. +
+
colorimetrics
+
+the processing and analysis of objects based on their colour. +
+
compression
+
+a class of data encoding methods that aims to reduce the size of a file +while retaining some or all of the information it contains. +
+
channel
+
+a set of pixel intensities within an image that were measured in the +same way e.g. at a given wavelength. +
+
crop
+
+the removal of unwanted outer areas from an image. +
+
colour histogram
+
+a representation of the number of pixels that have colours in each of a +fixed list of colour ranges. +
+
edge detection
+
+a variety of methods that attempt to automatically identify the +boundaries of objects within an image. +
+
fixed-level thresholding
+
+thresholding that uses a single, constant cut-off value for every pixel +in the image. +
+
grayscale
+
+an image in which the value of each pixel is a single value representing +only the amount of light (or intensity) of that pixel. +
+
histogram
+
+a graphical representation of the distribution of a set of numeric data, +usually a vertical bar graph. +
+
image segmentation
+
+the process of dividing an image into multiple sections, to be processed +or analysed independently. +
+
intensity
+
+the value measured at a given pixel in the image. +
+
JPEG
+
+a commonly used method of lossy compression for digital images, +particularly for those images produced by digital photography +
+
kernel
+
+a matrix, usually relatively small, defining a neighbourhood of pixel +intensities that will be considered during blurring, edge detection, and +other operations. +
+
left-hand coordinate system
+
+a system of coordinates where the origin is at the top-left extreme of +the image, and coordinates increase as you move down the y axis. +
+
lossy compression
+
+a class of data compression methods that uses inexact approximations and +partial data discarding to represent the content. +
+
lossless compression
+
+a class of data compression methods that allows the original data to be +perfectly reconstructed from the compressed data. +
+
maize
+
+a common crop plant grown in many regions of the world. Also known as +corn. +
+
mask
+
+a binary matrix, usually of the same dimensions as the target image, +representing which pixels should be included and excluded in further +processing and analysis. +
+
morphometrics
+
+the processing and analysis of objects based on their size and shape. +
+
noise
+
+random variation of brightness or colour information in images. An +undesirable by-product of image capture that obscures the desired +information. +
+
pixel
+
+the individual units of intensity that make up an image. +
+
raster +graphics
+
+images stored as a matrix of pixels. +
+
RGB colour model
+
+an additive colour model describing colour in a image with a combination +of pixel intensities in three channels: red, green, and blue. +
+
thresholding
+
+the process of creating a binary version of a grayscale image, based on +whether pixel values fall above or below a given limit or cut-off value. +
+
TIFF (Tagged Image File Format)
+
+a computer file format for storing raster graphics images; also +abbreviated TIF +
+
titration
+
+a common laboratory method of quantitative chemical analysis to +determine the concentration of an identified analyte (a substance to be +analyzed) +
+
+
+ + +
+
+ + + diff --git a/instructors/instructor-notes.md b/instructors/instructor-notes.md deleted file mode 100644 index c7884b318..000000000 --- a/instructors/instructor-notes.md +++ /dev/null @@ -1,81 +0,0 @@ ---- -title: Instructor Notes ---- - -## Estimated Timings - -This is a relatively new curriculum. -The estimated timings for each episode are based on limited experience -and should be taken as a rough guide only. -If you teach the curriculum, -the Maintainers would be delighted to receive feedback with -information about the time that was required -for teaching and exercises in each episode of your workshop. - -Please [open an issue on the repository](https://github.com/datacarpentry/image-processing/issues/new/choose) -to share your experience with the lesson Maintainers. - -## Working with Jupyter notebooks - -- This lesson is designed to be taught using Jupyter notebooks. We recommend that instructors guide learners to create a new Jupyter notebook for each episode. - -- Python `import` statements typically appear in the first code block near the top of each episode. In some cases, the purpose of specific libraries is briefly explained as part of the exercises. - -- The possibility of executing the code cells in a notebook in arbitrary order can cause confusion. Using the "restart kernel and run all cells" feature is one way to accomplish linear execution of the notebook and may help locate and identify coding issues. - -- Many episodes in this lesson load image files from disk. To avoid name clashes in episodes that load multiple image files, we have used unique variable names (instead of generic names such as `image` or `img`). When copying code snippets between exercises, the variable names may have to be changed. The maintainers are keen to receive feedback on whether this convention proves practical in workshops. - -## Working with imageio and skimage - -- `imageio.v3` allows to load images in different modes by passing the `mode=` argument to `imread()`. Depending on the image file and mode, the `dtype` of the resulting Numpy array can be different (e.g., `dtype('uint8')` or `dtype('float64')`. In the lesson, `skimage.util.img_as_ubyte()` and `skimage.util.img_as_float()` are used to convert the data type when necessary. - -- Some `skimage` functions implicitly convert the pixel values to floating-point numbers. Several callout boxes have been added throughout the lesson to raise awareness, but this may still prompt questions from learners. - -- In certain situations, `imread()` returns a read-only array. This depends on the image file type and on the backend (e.g., Pillow). If a read-only error is encountered, `image = np.array(image)` can be used to create a writable copy of the array before manipulating its pixel values. - -- Be aware that learners might get surprising results in the *Keeping only low intensity pixels* exercise, if `plt.imshow` is called without the `vmax` parameter. - A detailed explanation is given in the *Plotting single channel images (cmap, vmin, vmax)* callout box. - -## Additional resources - -- A cheat-sheet with graphics illustrating some concepts in this lesson is available: - - [Cheat-sheet HTML for viewing in browser](../episodes/files/cheatsheet.html). - - [PDF version for printing](../episodes/files/cheatsheet.pdf). - - -## Questions from Learners - -### Q: Where would I find out that coordinates are `x,y` not `r,c`? - -A: In an image viewer, hover your cursor over top-left (origin) the move down and see which number increases. - -### Q: Why does saving the image take such a long time? (skimage-images/saving images PNG example) - -A: It is a large image. - -### Q: Are the coordinates represented `x,y` or `r,c` in the code (e.g. in `array.shape`)? - -A: Always `r,c` with numpy arrays, unless clearly specified otherwise - only represented `x,y` when image is displayed by a viewer. -Take home is don't rely on it - always check! - -### Q: What if I want to increase size? How does `skimage` upsample? (image resizing) - -A: When resizing or rescaling an image, `skimage` performs interpolation to up-size or down-size the image. Technically, this is done by fitting a [spline](https://en.wikipedia.org/wiki/Spline_\(mathematics\)) function to the image data. The spline function is based on the intensity values in the original image and can be used to approximate the intensity at any given coordinate in the resized/rescaled image. Note that the intensity values in the new image are an approximation of the original values but should not be treated as the actual, observed data. `skimage.transform.resize` has a number of optional parameters that allow the user to control, e.g., the order of the spline interpolation. The [scikit-image documentation](https://scikit-image.org/docs/stable/api/skimage.transform.html#skimage.transform.resize) provides additional information on other parameters. - -### Q: Why are some lines missing from the sudoku image when it is displayed inline in a Jupyter Notebook? (skimage-images/low intensity pixels exercise) - -A: They are actually present in image but not shown due to interpolation. - -### Q: Does blurring take values of pixels already blurred, or is blurring done on original pixel values only? - -A: Blurring is done on original pixel values only. - -### Q: Can you blur while retaining edges? - -A: Yes, many different filters/kernels exist, some of which are designed to be edge-preserving. - -## Troubleshooting - -Learners reported a problem on some operating systems, that Shift\+Enter is prevented from running a cell in Jupyter when the caps lock key is active. - - diff --git a/key-points.html b/key-points.html new file mode 100644 index 000000000..9f3ce2c36 --- /dev/null +++ b/key-points.html @@ -0,0 +1,596 @@ + + + + + +Image Processing with Python: Key Points + + + + + + + + + + + + +
+
+ + + + + + +
+
+

Key Points

+ +

Introduction

+
+
    +
  • Simple Python and scikit-image techniques can be used to solve +genuine image analysis problems.
  • +
  • Morphometric problems involve the number, shape, and / or size of +the objects in an image.
  • +

Image Basics

+
+
    +
  • Digital images are represented as rectangular arrays of square +pixels.
  • +
  • Digital images use a left-hand coordinate system, with the origin in +the upper left corner, the x-axis running to the right, and the y-axis +running down. Some learners may prefer to think in terms of counting +down rows for the y-axis and across columns for the x-axis. Thus, we +will make an effort to allow for both approaches in our lesson +presentation.
  • +
  • Most frequently, digital images use an additive RGB model, with +eight bits for the red, green, and blue channels.
  • +
  • scikit-image images are stored as multi-dimensional NumPy +arrays.
  • +
  • In scikit-image images, the red channel is specified first, then the +green, then the blue, i.e., RGB.
  • +
  • Lossless compression retains all the details in an image, but lossy +compression results in loss of some of the original image detail.
  • +
  • BMP images are uncompressed, meaning they have high quality but also +that their file sizes are large.
  • +
  • JPEG images use lossy compression, meaning that their file sizes are +smaller, but image quality may suffer.
  • +
  • TIFF images can be uncompressed or compressed with lossy or lossless +compression.
  • +
  • Depending on the camera or sensor, various useful pieces of +information may be stored in an image file, in the image metadata.
  • +

Working with scikit-image

+
+
    +
  • Images are read from disk with the iio.imread() +function.
  • +
  • We create a window that automatically scales the displayed image +with Matplotlib and calling imshow() on the global figure +object.
  • +
  • Colour images can be transformed to grayscale using +ski.color.rgb2gray() or, in many cases, be read as +grayscale directly by passing the argument mode="L" to +iio.imread().
  • +
  • We can resize images with the ski.transform.resize() +function.
  • +
  • NumPy array commands, such as +image[image < 128] = 0, can be used to manipulate the +pixels of an image.
  • +
  • Array slicing can be used to extract sub-images or modify areas of +images, e.g., clip = image[60:150, 135:480, :].
  • +
  • Metadata is not retained when images are loaded as NumPy arrays +using iio.imread().
  • +

Drawing and Bitwise Operations

+
+
    +
  • We can use the NumPy zeros() function to create a +blank, black image.
  • +
  • We can draw on scikit-image images with functions such as +ski.draw.rectangle(), ski.draw.disk(), +ski.draw.line(), and more.
  • +
  • The drawing functions return indices to pixels that can be set +directly.
  • +

Creating Histograms

+
+
    +
  • In many cases, we can load images in grayscale by passing the +mode="L" argument to the iio.imread() +function.
  • +
  • We can create histograms of images with the +np.histogram function.
  • +
  • We can display histograms using ax.plot() with the +bin_edges and histogram values returned by +np.histogram().
  • +
  • The plot can be customised using ax.set_xlabel(), +ax.set_ylabel(), ax.set_xlim(), +ax.set_ylim(), and ax.set_title().
  • +
  • We can separate the colour channels of an RGB image using slicing +operations and create histograms for each colour channel +separately.
  • +

Blurring Images

+
+
    +
  • Applying a low-pass blurring filter smooths edges and removes noise +from an image.
  • +
  • Blurring is often used as a first step before we perform +thresholding or edge detection.
  • +
  • The Gaussian blur can be applied to an image with the +ski.filters.gaussian() function.
  • +
  • Larger sigma values may remove more noise, but they will also remove +detail from an image.
  • +

Thresholding

+
+
    +
  • Thresholding produces a binary image, where all pixels with +intensities above (or below) a threshold value are turned on, while all +other pixels are turned off.
  • +
  • The binary images produced by thresholding are held in +two-dimensional NumPy arrays, since they have only one colour value +channel. They are boolean, hence they contain the values 0 (off) and 1 +(on).
  • +
  • Thresholding can be used to create masks that select only the +interesting parts of an image, or as the first step before edge +detection or finding contours.
  • +

Connected Component Analysis

+
+
    +
  • We can use ski.measure.label to find and label +connected objects in an image.
  • +
  • We can use ski.measure.regionprops to measure +properties of labeled objects.
  • +
  • We can use ski.morphology.remove_small_objects to mask +small objects and remove artifacts from an image.
  • +
  • We can display the labeled image to view the objects coloured by +label.
  • +

Capstone Challenge

+
+
    +
  • Using thresholding, connected component analysis and other tools we +can automatically segment images of bacterial colonies.
  • +
  • These methods are useful for many scientific problems, especially +those involving morphometrics.
  • +
+
+
+
+ + +
+ + +
+ + + + + diff --git a/learners/discuss.md b/learners/discuss.md deleted file mode 100644 index e42fbaf79..000000000 --- a/learners/discuss.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -title: Discussion ---- - -## Choice of Image Processing Library - -This lesson was originally designed to use [OpenCV](https://opencv.org/) -and the [`opencv-python`](https://pypi.org/project/opencv-python/) -library ([see the last version of the lesson repository to use -OpenCV](https://github.com/datacarpentry/image-processing/tree/770a2416fb5c6bd5a4b8e728b3e338667e47b0ed)). - -In 2019-2020 the lesson was adapted to use -[scikit-image](https://scikit-image.org/), as this library has proven -easier to install and enjoys more extensive documentation and support. - -## Choice of Image Viewer - -When the lesson was first adapted to use sckikit-image (see above), -`skimage.viewer.ImageViewer` was used to inspect images. [This viewer -is deprecated](https://scikit-image.org/docs/stable/user_guide/visualization.html) -and the lesson maintainers chose to leverage `matplotlib.pyplot.imshow` -with the pan/zoom and mouse-location tools built into the [Matplotlib -GUI](https://matplotlib.org/stable/users/interactive.html). The -[`ipympl` package](https://github.com/matplotlib/ipympl) is required -to enable the interactive features of Matplotlib in Jupyter notebooks -and in Jupyter Lab. This package is included in the setup -instructions, and the backend can be enabled using the `%matplotlib widget` magic. - -The maintainers discussed the possibility of using [napari](https://napari.org/) -as an image viewer in the lesson, acknowledging its growing popularity -and some of the advantages it holds over the Matplotlib-based -approach, especially for working with image data in more than two -dimensions. However, at the time of discussion, napari was still in -an alpha state of development, and could not be relied on for easy and -error-free installation on all operating systems, which makes it less -well-suited to use in an official Data Carpentry curriculum. - -The lesson Maintainers and/or Curriculum Advisory Committee (when it -exists) will monitor the progress of napari and other image viewers, -and may opt to adopt a new platform in future. diff --git a/learners/edge-detection.md b/learners/edge-detection.md deleted file mode 100644 index 53d876513..000000000 --- a/learners/edge-detection.md +++ /dev/null @@ -1,488 +0,0 @@ ---- -title: 'Extra Episode: Edge Detection' -teaching: 0 -exercises: 0 ---- - -:::::::::::::::::::::::::::::: questions - -- How can we automatically detect the edges of the objects in an image? - -:::::::::::::::::::::::::::::::::::::::: - -:::::::::::::::::::::::::::::: objectives - -- Apply Canny edge detection to an image. -- Explain how we can use sliders to expedite finding appropriate parameter values - for our scikit-image function calls. -- Create scikit-image windows with sliders and associated callback functions. - -::::::::::::::::::::::::::::::::::::::::: - -In this episode, we will learn how to use scikit-image functions to apply *edge -detection* to an image. -In edge detection, we find the boundaries or edges of objects in an image, -by determining where the brightness of the image changes dramatically. -Edge detection can be used to extract the structure of objects in an image. -If we are interested in the number, -size, -shape, -or relative location of objects in an image, -edge detection allows us to focus on the parts of the image most helpful, -while ignoring parts of the image that will not help us. - -For example, once we have found the edges of the objects in the image -(or once we have converted the image to binary using thresholding), -we can use that information to find the image *contours*, -which we will learn about in -[the *Connected Component Analysis* episode](../episodes/08-connected-components.md). -With the contours, -we can do things like counting the number of objects in the image, -measure the size of the objects, classify the shapes of the objects, and so on. - -As was the case for blurring and thresholding, -there are several different methods in scikit-image that can be used for edge detection, -so we will examine only one in detail. - -## Introduction to edge detection - -To begin our introduction to edge detection, -let us look at an image with a very simple edge - -this grayscale image of two overlapped pieces of paper, -one black and and one white: - -![](fig/black-and-white.jpg){alt='Black and white image'} - -The obvious edge in the image is the vertical line -between the black paper and the white paper. -To our eyes, -there is a quite sudden change between the black pixels and the white pixels. -But, at a pixel-by-pixel level, is the transition really that sudden? - -If we zoom in on the edge more closely, as in this image, we can see -that the edge between the black and white areas of the image is not a clear-cut line. - -![](fig/black-and-white-edge-pixels.jpg){alt='Black and white edge pixels'} - -We can learn more about the edge by examining the colour values of some of the pixels. -Imagine a short line segment, -halfway down the image and straddling the edge between the black and white paper. -This plot shows the pixel values -(between 0 and 255, since this is a grayscale image) -for forty pixels spanning the transition from black to white. - -![](fig/black-and-white-gradient.png){alt='Gradient near transition'} - -It is obvious that the "edge" here is not so sudden! -So, any scikit-image method to detect edges in an image must be able to -decide where the edge is, and place appropriately-coloured pixels in that location. - -## Canny edge detection - -Our edge detection method in this workshop is *Canny edge detection*, -created by John Canny in 1986. -This method uses a series of steps, some incorporating other types of edge detection. -The `skimage.feature.canny()` function performs the following steps: - -1. A Gaussian blur - (that is characterised by the `sigma` parameter, - see [*Blurring Images*](../episodes/06-blurring.md) - is applied to remove noise from the image. - (So if we are doing edge detection via this function, - we should not perform our own blurring step.) -2. Sobel edge detection is performed on both the cx and ry dimensions, - to find the intensity gradients of the edges in the image. - Sobel edge detection computes - the derivative of a curve fitting the gradient between light and dark areas - in an image, and then finds the peak of the derivative, - which is interpreted as the location of an edge pixel. -3. Pixels that would be highlighted, but seem too far from any edge, - are removed. - This is called *non-maximum suppression*, and - the result is edge lines that are thinner than those produced by other methods. -4. A double threshold is applied to determine potential edges. - Here extraneous pixels caused by noise or milder colour variation than desired - are eliminated. - If a pixel's gradient value - based on the Sobel differential - - is above the high threshold value, - it is considered a strong candidate for an edge. - If the gradient is below the low threshold value, it is turned off. - If the gradient is in between, - the pixel is considered a weak candidate for an edge pixel. -5. Final detection of edges is performed using *hysteresis*. - Here, weak candidate pixels are examined, and - if they are connected to strong candidate pixels, - they are considered to be edge pixels; - the remaining, non-connected weak candidates are turned off. - -For a user of the `skimage.feature.canny()` edge detection function, -there are three important parameters to pass in: -`sigma` for the Gaussian filter in step one and -the low and high threshold values used in step four of the process. -These values generally are determined empirically, -based on the contents of the image(s) to be processed. - -The following program illustrates how the `skimage.feature.canny()` method -can be used to detect the edges in an image. -We will execute the program on the `data/shapes-01.jpg` image, -which we used before in -[the *Thresholding* episode](../episodes/07-thresholding.md): - -![](fig/shapes-01.jpg){alt='coloured shapes'} - -We are interested in finding the edges of the shapes in the image, -and so the colours are not important. -Our strategy will be to read the image as grayscale, -and then apply Canny edge detection. -Note that when reading the image with `iio.imread(..., mode="L")` -the image is converted to a grayscale image of same dtype. - -This program takes three command-line arguments: -the filename of the image to process, -and then two arguments related to the double thresholding -in step four of the Canny edge detection process. -These are the low and high threshold values for that step. -After the required libraries are imported, -the program reads the command-line arguments and -saves them in their respective variables. - -```python -"""Python script to demonstrate Canny edge detection. - -usage: python CannyEdge.py -""" -import imageio.v3 as iio -import matplotlib.pyplot as plt -import skimage.feature -import sys - -# read command-line arguments -filename = sys.argv[1] -sigma = float(sys.argv[2]) -low_threshold = float(sys.argv[3]) -high_threshold = float(sys.argv[4]) -``` - -Next, the original images is read, in grayscale, and displayed. - -```python -# load and display original image as grayscale -image = iio.imread(uri=filename, mode="L") -plt.imshow(image) -``` - -Then, we apply Canny edge detection with this function call: - -```python -edges = skimage.feature.canny( - image=image, - sigma=sigma, - low_threshold=low_threshold, - high_threshold=high_threshold, -) -``` - -As we are using it here, the `skimage.feature.canny()` function takes four parameters. -The first parameter is the input image. -The `sigma` parameter determines -the amount of Gaussian smoothing that is applied to the image. -The next two parameters are the low and high threshold values -for the fourth step of the process. - -The result of this call is a binary image. -In the image, the edges detected by the process are white, -while everything else is black. - -Finally, the program displays the `edges` image, -showing the edges that were found in the original. - -```python -# display edges -skimage.io.imshow(edges) -``` - -Here is the result, for the coloured shape image above, -with sigma value 2.0, low threshold value 0.1 and high threshold value 0.3: - -![](fig/shapes-01-canny-edges.png){alt='Output file of Canny edge detection'} - -Note that the edge output shown in a scikit-image window may look significantly -worse than the image would look -if it were saved to a file due to resampling artefacts in the interactive image viewer. -The image above is the edges of the junk image, saved in a PNG file. -Here is how the same image looks when displayed in a scikit-image output window: - -![](fig/shapes-01-canny-edge-output.png){alt='Output window of Canny edge detection'} - -## Interacting with the image viewer using viewer plugins - -As we have seen, for a user of the `skimage.feature.canny()` edge detection function, -three important parameters to pass in are sigma, -and the low and high threshold values used in step four of the process. -These values generally are determined empirically, -based on the contents of the image(s) to be processed. - -Here is an image of some glass beads that we can use as -input into a Canny edge detection program: - -![](fig/beads.jpg){alt='Beads image'} - -We could use the `code/edge-detection/CannyEdge.py` program above -to find edges in this image. -To find acceptable values for the thresholds, -we would have to run the program over and over again, -trying different threshold values and examining the resulting image, -until we find a combination of parameters that works best for the image. - -*Or*, we can write a Python program and -create a viewer plugin that uses scikit-image *sliders*, -that allow us to vary the function parameters while the program is running. -In other words, we can write a program that presents us with a window like this: - -![](fig/beads-canny-ui.png){alt='Canny UI'} - -Then, when we run the program, we can use the sliders to -vary the values of the sigma and threshold parameters -until we are satisfied with the results. -After we have determined suitable values, -we can use the simpler program to utilise the parameters without -bothering with the user interface and sliders. - -Here is a Python program that shows how to apply Canny edge detection, -and how to add sliders to the user interface. -There are four parts to this program, -making it a bit (but only a *bit*) -more complicated than the programs we have looked at so far. -The added complexity comes from setting up the sliders for the parameters -that were previously read from the command line: -In particular, we have added - -- The `canny()` filter function that returns an edge image, -- The `cannyPlugin` plugin object, to which we add -- The sliders for *sigma*, and *low* and *high threshold* values, and -- The main program, i.e., the code that is executed when the program runs. - -We will look at the main program part first, and then return to writing the plugin. -The first several lines of the main program are easily recognizable at this point: -saving the command-line argument, -reading the image in grayscale, -and creating a window. - -```python -"""Python script to demonstrate Canny edge detection with sliders to adjust the thresholds. - -usage: python CannyTrack.py -""" -import imageio.v3 as iio -import matplotlib.pyplot as plt -import skimage.feature -import skimage.viewer -import sys - - -filename = sys.argv[1] -image = iio.imread(uri=filename, mode="L") -viewer = plt.imshow(image) -``` - -The `skimage.viewer.plugins.Plugin` class is designed to manipulate images. -It takes an `image_filter` argument in the constructor that should be a function. -This function should produce a new image as an output, -given an image as the first argument, -which then will be automatically displayed in the image viewer. - -```python -# Create the plugin and give it a name -canny_plugin = skimage.viewer.plugins.Plugin(image_filter=skimage.feature.canny) -canny_plugin.name = "Canny Filter Plugin" -``` - -We want to interactively modify the parameters of the filter function interactively. -scikit-image allows us to further enrich the plugin by adding widgets, like -`skimage.viewer.widgets.Slider`, -`skimage.viewer.widgets.CheckBox`, -`skimage.viewer.widgets.ComboBox`. -Whenever a widget belonging to the plugin is updated, -the filter function is called with the updated parameters. -This function is also called a callback function. -The following code adds sliders for `sigma`, `low_threshold` and `high_thresholds`. - -```python -# Add sliders for the parameters -canny_plugin += skimage.viewer.widgets.Slider( - name="sigma", low=0.0, high=7.0, value=2.0 -) -canny_plugin += skimage.viewer.widgets.Slider( - name="low_threshold", low=0.0, high=1.0, value=0.1 -) -canny_plugin += skimage.viewer.widgets.Slider( - name="high_threshold", low=0.0, high=1.0, value=0.2 -) -``` - -A slider is a widget that lets you choose a number by dragging a handle along a line. -On the left side of the line, we have the lowest value, -on the right side the highest value that can be chosen. -The range of values in between is distributed equally along this line. -All three sliders are constructed in the same way: -The first argument is the name of the parameter that is tweaked by the slider. -With the arguments `low`, and `high`, -we supply the limits for the range of numbers that is represented by the slider. -The `value` argument specifies the initial value of that parameter, -so where the handle is located when the plugin is started. -Adding the slider to the plugin makes the values available as -parameters to the `filter_function`. - -::::::::::::::::::::::::::::::::::::::::: callout - -## How does the plugin know how to call the filter function with the parameters? - -The filter function will be called with the slider parameters -according to their *names* as *keyword* arguments. -So it is very important to name the sliders appropriately. - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -Finally, we add the plugin the viewer and display the resulting user interface: - -```python -# add the plugin to the viewer and show the window -viewer += canny_plugin -viewer.show() -``` - -Here is the result of running the preceding program on the beads image, -with a sigma value 1.0, -low threshold value 0.1 and high threshold value 0.3. -The image shows the edges in an output file. - -![](fig/beads-out.png){alt='Beads edges (file)'} - -::::::::::::::::::::::::::::::::::::::: challenge - -## Applying Canny edge detection to another image (5 min) - -Now, run the program above on the image of coloured shapes, -`data/shapes-01.jpg`. -Use a sigma of 1.0 and adjust low and high threshold sliders -to produce an edge image that looks like this: - -![](fig/shapes-01-canny-track-edges.png){alt='coloured shape edges'} - -What values for the low and high threshold values did you use to -produce an image similar to the one above? - -::::::::::::::: solution - -## Solution - -The coloured shape edge image above was produced with a low threshold -value of 0.05 and a high threshold value of 0.07. -You may be able to achieve similar results with other threshold values. - -::::::::::::::::::::::::: - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -::::::::::::::::::::::::::::::::::::::: challenge - -## Using sliders for thresholding (30 min) - -Now, let us apply what we know about creating sliders to another, -similar situation. -Consider this image of a collection of maize seedlings, -and suppose we wish to use simple fixed-level thresholding to -mask out everything that is not part of one of the plants. - -![](fig/maize-roots-grayscale.jpg){alt='Maize roots image'} - -To perform the thresholding, we could first create a histogram, -then examine it, and select an appropriate threshold value. -Here, however, let us create an application with a slider to set the threshold value. -Create a program that reads in the image, -displays it in a window with a slider, -and allows the slider value to vary the threshold value used. -You will find the image at `data/maize-roots-grayscale.jpg`. - -::::::::::::::: solution - -## Solution - -Here is a program that uses a slider to vary the threshold value used in -a simple, fixed-level thresholding process. - -```python -"""Python program to use a slider to control fixed-level thresholding value. - -usage: python interactive_thresholding.py -""" - -import imageio.v3 as iio -import skimage -import skimage.viewer -import sys - -filename = sys.argv[1] - - -def filter_function(image, sigma, threshold): - masked = image.copy() - masked[skimage.filters.gaussian(image, sigma=sigma) <= threshold] = 0 - return masked - - -smooth_threshold_plugin = skimage.viewer.plugins.Plugin( - image_filter=filter_function -) - -smooth_threshold_plugin.name = "Smooth and Threshold Plugin" - -smooth_threshold_plugin += skimage.viewer.widgets.Slider( - "sigma", low=0.0, high=7.0, value=1.0 -) -smooth_threshold_plugin += skimage.viewer.widgets.Slider( - "threshold", low=0.0, high=1.0, value=0.5 -) - -image = iio.imread(uri=filename, mode="L") - -viewer = skimage.viewer.ImageViewer(image=image) -viewer += smooth_threshold_plugin -viewer.show() -``` - -Here is the output of the program, -blurring with a sigma of 1.5 and a threshold value of 0.45: - -![](fig/maize-roots-threshold.png){alt='Thresholded maize roots'} - -::::::::::::::::::::::::: - -:::::::::::::::::::::::::::::::::::::::::::::::::: - -Keep this plugin technique in your image processing "toolbox." -You can use sliders (or other interactive elements, -see [the scikit-image documentation](https://scikit-image.org/docs/dev/api/skimage.viewer.widgets.html)) -to vary other kinds of parameters, such as sigma for blurring, -binary thresholding values, and so on. -A few minutes developing a program to tweak parameters like this can -save you the hassle of repeatedly running a program from the command line -with different parameter values. -Furthermore, scikit-image already comes with a few viewer plugins that you can -check out in [the documentation](https://scikit-image.org/docs/dev/api/skimage.viewer.plugins.html). - -## Other edge detection functions - -As with blurring, there are other options for finding edges in skimage. -These include `skimage.filters.sobel()`, -which you will recognise as part of the Canny method. -Another choice is `skimage.filters.laplace()`. - -:::::::::::::::::::::::::::::: keypoints - -- The `skimage.viewer.ImageViewer` is extended using a `skimage.viewer.plugins.Plugin`. -- We supply a filter function callback when creating a Plugin. -- Parameters of the callback function are manipulated interactively by creating sliders - with the `skimage.viewer.widgets.slider()` function and adding them to the plugin. - -:::::::::::::::::::::::::::::::::::::::: diff --git a/learners/further-reading.md b/learners/further-reading.md deleted file mode 100644 index 52eb196ca..000000000 --- a/learners/further-reading.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -title: Further Reading -permalink: /reading/ ---- - -## Where to go from here - -- [Scikit-image YouTube tutorials](https://www.youtube.com/playlist?list=PLBKcU7Ik-ir9Fi_hM_A6_U2UTpm7ACUtl) -- [Principles of Scientific Imaging](https://imagej.net/imaging/principles) -- [Data Handling and Management Training](https://carpentries-incubator.github.io/Data-Management-Training/) -- [Introduction to Bioimage Analysis by Pete Bankhead](https://bioimagebook.github.io/index.html) -- [Bio-image Analysis Notebooks by Robert Haase](https://haesleinhuepf.github.io/BioImageAnalysisNotebooks/intro.html) -- [Introduction to artificial neural networks in Python](https://carpentries-incubator.github.io/machine-learning-neural-python/index.html) -- [Building Better Research Software](https://carpentries-incubator.github.io/better-research-software/) - -## Where to find more help - -- [Image.sc Forum](https://image.sc/) -- [NFDI4BioImage Training Materials collection](https://nfdi4bioimage.github.io/training/readme.html) -- [Curated list of image analysis resources by EPFL Center for Imaging](https://github.com/EPFL-Center-for-Imaging/awesome-scientific-image-analysis) - -## Scientific Literature - -- [Digital Image Processing](https://www.imageprocessingplace.com), Textbook by Rafael C. Gonzalez and Richard E. Woods -- [Checklist for publishing images and analyses ](https://www.nature.com/articles/s41592-023-01987-9) -- [REMBI: Recommended Metadata for Biological Images](https://www.nature.com/articles/s41592-021-01166-8) -- ["Twenty questions": a schema for a set of questions to guide analyses](https://www.nature.com/articles/s41592-023-01919-7) -- [From cells to pixels: A decision tree for designing bioimage analysis pipelines](https://onlinelibrary.wiley.com/doi/10.1111/jmi.70021) - diff --git a/learners/prereqs.md b/learners/prereqs.md deleted file mode 100644 index 1a5043d86..000000000 --- a/learners/prereqs.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -title: Prerequisites ---- - -This lesson assumes you have a working knowledge of Python and some previous exposure to the Bash shell. - -These requirements can be fulfilled by: - -1. completing a Software Carpentry Python workshop **or** -2. completing a Data Carpentry Ecology workshop (with Python) **and** a Data Carpentry Genomics workshop **or** -3. coursework in or independent learning of both Python and the Bash shell. - -### Bash shell skills - -The skill set listed below is covered in any Software Carpentry workshop, as well -as in Data Carpentry's Genomics workshop. These skills can also be learned -through coursework or independent learning. - -Be able to: - -- Identify and navigate to your home directory. -- Identify your current working directory. -- Navigating directories using `pwd`, `ls`, `cd `, and `cd ..` -- Run a Python script from the command line. - -### Python skills - -This skill set listed below is covered in both Software Carpentry's Python workshop and -in Data Carpentry's Ecology workshop with Python. These skills can also be learned -through coursework or independent learning. - -Be able to: - -- Use the assignment operator to create `int`, `float`, and `str` variables. -- Perform basic arithmetic operations (e.g. addition, subtraction) on variables. -- Convert strings to ints or floats where appropriate. -- Create a `list` and alter lists by appending, inserting, or removing values. -- Use indexing and slicing to access elements of strings, lists, and NumPy arrays. -- Use good coding practices to comment your code and choose appropriate variable names. -- Write a `for` loop that increments a variable. -- Write conditional statements using `if`, `elif`, and `else`. -- Use comparison operators (`==`, `!=`, `<`, `<=`, `>`, `>=`) in conditional statements. -- Read data from a file using `read()`, `readline()`, and `readlines()`. -- Open, read from, write to, and close input and output files. -- Use `print()` and `len()` to inspect variables. - -The following skills are useful, but not required: - -- Apply a function to an entire NumPy array or to a single array axis. -- Write a user-defined function. - -If you are signed up, or considering signing up for a workshop, and aren't sure whether you meet these reqirements, please -get in touch with the workshop instructors or host. diff --git a/learners/reference.md b/learners/reference.md deleted file mode 100644 index 6c4e4f65a..000000000 --- a/learners/reference.md +++ /dev/null @@ -1,118 +0,0 @@ ---- -title: 'Glossary' ---- - -## Glossary - -(Some definitions are taken from [Glosario](https://glosario.carpentries.org). -Follow the links from terms to see definitions in languages other than English.) - -{:auto\_ids} - -adaptive thresholding -: thresholding that uses a cut-off value that varies for pixels in different regions of the image. - -additive colour model -: a colour model that predicts the appearance of colours by summing the numeric representations of the component colours. - -bacterial colony -: a visible cluster of bacteria growing on the surface of or within a solid medium, presumably cultured from a single cell. - -binary image -: an image of pixels with only two possible values, 0 and 1. Typically, the two colours used for a binary image are black and white. - -[bit](https://glosario.carpentries.org/en/#bit) -: a unit of information representing alternatives, yes/no, true/false. In computing a state of either 0 or 1. - -blur -: the averaging of pixel intensities within a neighbourhood. This has the effect of "softening" the features of the image, reducing noise and finer detail. - -BMP (bitmap image file) -: a raster graphics image file format used to store bitmap digital images, independently of the display device. - -bounding box -: the smallest enclosing box for a set of points. - -[byte](https://glosario.carpentries.org/en/#byte) -: a unit of digital information that typically consists of eight binary digits, or bits. - -colorimetrics -: the processing and analysis of objects based on their colour. - -compression -: a class of data encoding methods that aims to reduce the size of a file while retaining some or all of the information it contains. - -channel -: a set of pixel intensities within an image that were measured in the same way e.g. at a given wavelength. - -crop -: the removal of unwanted outer areas from an image. - -colour histogram -: a representation of the number of pixels that have colours in each of a fixed list of colour ranges. - -edge detection -: a variety of methods that attempt to automatically identify the boundaries of objects within an image. - -fixed-level thresholding -: thresholding that uses a single, constant cut-off value for every pixel in the image. - -grayscale -: an image in which the value of each pixel is a single value representing only the amount of light (or intensity) of that pixel. - -[histogram](https://glosario.carpentries.org/en/#histogram) -: a graphical representation of the distribution of a set of numeric data, usually a vertical bar graph. - -image segmentation -: the process of dividing an image into multiple sections, to be processed or analysed independently. - -intensity -: the value measured at a given pixel in the image. - -JPEG -: a commonly used method of lossy compression for digital images, particularly for those images produced by digital photography - -kernel -: a matrix, usually relatively small, defining a neighbourhood of pixel intensities that will be considered during blurring, edge detection, and other operations. - -left-hand coordinate system -: a system of coordinates where the origin is at the top-left extreme of the image, and coordinates increase as you move down the y axis. - -lossy compression -: a class of data compression methods that uses inexact approximations and partial data discarding to represent the content. - -lossless compression -: a class of data compression methods that allows the original data to be perfectly reconstructed from the compressed data. - -maize -: a common crop plant grown in many regions of the world. Also known as corn. - -mask -: a binary matrix, usually of the same dimensions as the target image, representing which pixels should be included and excluded in further processing and analysis. - -morphometrics -: the processing and analysis of objects based on their size and shape. - -noise -: random variation of brightness or colour information in images. An undesirable by-product of image capture that obscures the desired information. - -pixel -: the individual units of intensity that make up an image. - -[raster graphics](https://glosario.carpentries.org/en/#raster_image) -: images stored as a matrix of pixels. - -RGB colour model -: an additive colour model describing colour in a image with a combination of pixel intensities in three channels: red, green, and blue. - -thresholding -: the process of creating a binary version of a grayscale image, based on whether pixel values fall above or below a given limit or cut-off value. - -TIFF (Tagged Image File Format) -: a computer file format for storing raster graphics images; also -abbreviated TIF - -titration -: a common laboratory method of quantitative chemical analysis to determine the concentration of an identified analyte (a substance to be analyzed) - - diff --git a/learners/setup.md b/learners/setup.md deleted file mode 100644 index 43f49f299..000000000 --- a/learners/setup.md +++ /dev/null @@ -1,131 +0,0 @@ ---- -title: Setup -permalink: /setup/ ---- - -Before joining the workshop or following the lesson, please complete the data and software setup described in this page. - -## Data - -The example images and a description of the Python environment used in this lesson are available on [FigShare](https://figshare.com/). -To download the data, please visit [the dataset page for this workshop][figshare-data] -and click the "Download all" button. -Unzip the downloaded file, and save the contents as a folder called `data` somewhere you will easily find it again, -e.g. your Desktop or a folder you have created for using in this workshop. -(The name `data` is optional but recommended, as this is the name we will use to refer to the folder throughout the lesson.) - -## Software - -1. Download and install the latest [Miniforge distribution of Python](https://conda-forge.org/download/) for your operating system. - ([See more detailed instructions from The Carpentries](https://carpentries.github.io/workshop-template/#python-1).) - If you already have a Python 3 setup that you are happy with, you can continue to use that (we recommend that you make sure your Python version is current). - The next step assumes that `conda` is available to manage your Python environment. -2. Set up an environment to work in during the lesson. - In a terminal (Linux/Mac) or the MiniForge Prompt application (Windows), navigate to the location where you saved the unzipped data for the lesson and run the following command: - - ```bash - conda env create -f environment.yml - ``` - - If prompted, allow `conda` to install the required libraries. -3. Activate the new environment you just created: - - ```bash - conda activate dc-image - ``` - - ::::::::::::::::::::::::::::::::::::::::: callout - - ## Enabling the `ipympl` backend in Jupyter notebooks - - The `ipympl` backend can be enabled with the `%matplotlib` Jupyter - magic. Put the following command in a cell in your notebooks - (e.g., at the top) and execute the cell before any plotting commands. - - ```python - %matplotlib widget - ``` - - :::::::::::::::::::::::::::::::::::::::::::::::::: - - ::::::::::::::::::::::::::::::::::::::::: callout - - ## Older JupyterLab versions - - If you are using an older version of JupyterLab, you may also need - to install the labextensions manually, as explained in the [README - file](https://github.com/matplotlib/ipympl#readme) for the `ipympl` - package. - - - :::::::::::::::::::::::::::::::::::::::::::::::::: - -3. Open a Jupyter notebook: - - :::::::::::::::: spoiler - - ## Instructions for Linux \& Mac - - Open a terminal and type `jupyter lab`. - - - ::::::::::::::::::::::::: - - :::::::::::::::: spoiler - - ## Instructions for Windows - - Launch the Miniforge Prompt program and type `jupyter lab`. - (Running this command on the standard Command Prompt will return an error: - `'jupyter' is not recognized as an internal or external command, operable program or batch file.`) - - - ::::::::::::::::::::::::: - - After Jupyter Lab has launched, click the "Python 3" button under "Notebook" in the launcher window, - or use the "File" menu, to open a new Python 3 notebook. - -4. To test your environment, run the following lines in a cell of the notebook: - - ```python - import imageio.v3 as iio - import matplotlib.pyplot as plt - import skimage as ski - - %matplotlib widget - - # load an image - image = iio.imread(uri='data/colonies-01.tif') - - # rotate it by 45 degrees - rotated = ski.transform.rotate(image=image, angle=45) - - # display the original image and its rotated version side by side - fig, ax = plt.subplots(1, 2) - ax[0].imshow(image) - ax[1].imshow(rotated) - ``` - - Upon execution of the cell, a figure with two images should be displayed in an interactive widget. When hovering over the images with the mouse pointer, the pixel coordinates and colour values are displayed below the image. - - :::::::::::::::: spoiler - - ## Running Cells in a Notebook - - ![](fig/jupyter_overview.png){alt='Overview of the Jupyter Notebook graphical user interface'} - To run Python code in a Jupyter notebook cell, click on a cell in the notebook - (or add a new one by clicking the `+` button in the toolbar), - make sure that the cell type is set to "Code" (check the dropdown in the toolbar), - and add the Python code in that cell. - After you have added the code, - you can run the cell by selecting "Run" -> "Run selected cell" in the top menu, - or pressing Shift\+Enter. - - - ::::::::::::::::::::::::: - -5. A small number of exercises will require you to run commands in a terminal. Windows users should -use PowerShell for this. PowerShell is probably installed by default but if not you should -[download and install](https://apps.microsoft.com/detail/9MZ1SNWT0N5D?hl=en-eg&gl=EG) it. - -[figshare-data]: https://figshare.com/articles/dataset/Data_Carpentry_Image_Processing_Data_beta_/19260677 diff --git a/link.svg b/link.svg new file mode 100644 index 000000000..88ad82769 --- /dev/null +++ b/link.svg @@ -0,0 +1,12 @@ + + + + + + diff --git a/md5sum.txt b/md5sum.txt new file mode 100644 index 000000000..8e2f754ff --- /dev/null +++ b/md5sum.txt @@ -0,0 +1,22 @@ +"file" "checksum" "built" "date" +"CODE_OF_CONDUCT.md" "c93c83c630db2fe2462240bf72552548" "site/built/CODE_OF_CONDUCT.md" "2023-04-25" +"LICENSE.md" "e94126b93e27bae014999c2e84ee93f8" "site/built/LICENSE.md" "2025-02-04" +"config.yaml" "98f3a48a22d4e5bffc2f387a7549b0c9" "site/built/config.yaml" "2026-03-23" +"index.md" "6e80c662708984307918adfad711e15f" "site/built/index.md" "2023-07-26" +"episodes/01-introduction.md" "4fe9db38f75f93b3ffbdb3115d36b802" "site/built/01-introduction.md" "2024-11-28" +"episodes/02-image-basics.md" "e96045fe38777bd24cf11b76464f2eca" "site/built/02-image-basics.md" "2024-12-01" +"episodes/03-skimage-images.md" "d315488384f53b42cba0529550875145" "site/built/03-skimage-images.md" "2026-03-20" +"episodes/04-drawing.md" "d8c686bdf8b610fe5b66a669e3685fb5" "site/built/04-drawing.md" "2026-03-20" +"episodes/05-creating-histograms.md" "309e406d538ff298286dd52175ec0692" "site/built/05-creating-histograms.md" "2026-03-20" +"episodes/06-blurring.md" "3046847c190dd567652bb7cd0538ecd9" "site/built/06-blurring.md" "2026-03-20" +"episodes/07-thresholding.md" "69f5de6e19030f4935f72af98a762c2b" "site/built/07-thresholding.md" "2026-04-28" +"episodes/08-connected-components.md" "d8f4a9616c448bd8b72d3877305b8201" "site/built/08-connected-components.md" "2026-03-20" +"episodes/09-challenges.md" "7665ef69e61f7ea2184a58162c53aef2" "site/built/09-challenges.md" "2026-03-23" +"instructors/instructor-notes.md" "e8e378a5dfaec7b2873d788be85003ce" "site/built/instructor-notes.md" "2024-06-18" +"learners/prereqs.md" "7ca883d3d01d18c98ce7524ed297e56c" "site/built/prereqs.md" "2023-07-26" +"learners/discuss.md" "ad762c335f99400dc2cd1a8aad36bdbd" "site/built/discuss.md" "2023-07-26" +"learners/reference.md" "4b64eba548e2fcfc8c9e64158c50af71" "site/built/reference.md" "2026-03-23" +"learners/further-reading.md" "4310a4925099f32b68523228d5660432" "site/built/further-reading.md" "2026-03-23" +"learners/edge-detection.md" "3280446a1ae648dc21c78426b5804249" "site/built/edge-detection.md" "2026-03-20" +"learners/setup.md" "36ceef28a0c5cbd8e7f924dc0d151893" "site/built/setup.md" "2025-05-19" +"profiles/learner-profiles.md" "60b93493cf1da06dfd63255d73854461" "site/built/learner-profiles.md" "2023-04-25" diff --git a/mstile-150x150.png b/mstile-150x150.png new file mode 100644 index 000000000..8136f75e7 Binary files /dev/null and b/mstile-150x150.png differ diff --git a/pkgdown.css b/pkgdown.css new file mode 100644 index 000000000..80ea5b838 --- /dev/null +++ b/pkgdown.css @@ -0,0 +1,384 @@ +/* Sticky footer */ + +/** + * Basic idea: https://philipwalton.github.io/solved-by-flexbox/demos/sticky-footer/ + * Details: https://github.com/philipwalton/solved-by-flexbox/blob/master/assets/css/components/site.css + * + * .Site -> body > .container + * .Site-content -> body > .container .row + * .footer -> footer + * + * Key idea seems to be to ensure that .container and __all its parents__ + * have height set to 100% + * + */ + +html, body { + height: 100%; +} + +body { + position: relative; +} + +body > .container { + display: flex; + height: 100%; + flex-direction: column; +} + +body > .container .row { + flex: 1 0 auto; +} + +footer { + margin-top: 45px; + padding: 35px 0 36px; + border-top: 1px solid #e5e5e5; + color: #666; + display: flex; + flex-shrink: 0; +} +footer p { + margin-bottom: 0; +} +footer div { + flex: 1; +} +footer .pkgdown { + text-align: right; +} +footer p { + margin-bottom: 0; +} + +img.icon { + float: right; +} + +/* Ensure in-page images don't run outside their container */ +.contents img { + max-width: 100%; + height: auto; +} + +/* Fix bug in bootstrap (only seen in firefox) */ +summary { + display: list-item; +} + +/* Typographic tweaking ---------------------------------*/ + +.contents .page-header { + margin-top: calc(-60px + 1em); +} + +dd { + margin-left: 3em; +} + +/* Section anchors ---------------------------------*/ + +a.anchor { + display: none; + margin-left: 5px; + width: 20px; + height: 20px; + + background-image: url(./link.svg); + background-repeat: no-repeat; + background-size: 20px 20px; + background-position: center center; +} + +h1:hover .anchor, +h2:hover .anchor, +h3:hover .anchor, +h4:hover .anchor, +h5:hover .anchor, +h6:hover .anchor { + display: inline-block; +} + +/* Fixes for fixed navbar --------------------------*/ + +.contents h1, .contents h2, .contents h3, .contents h4 { + padding-top: 60px; + margin-top: -40px; +} + +/* Navbar submenu --------------------------*/ + +.dropdown-submenu { + position: relative; +} + +.dropdown-submenu>.dropdown-menu { + top: 0; + left: 100%; + margin-top: -6px; + margin-left: -1px; + border-radius: 0 6px 6px 6px; +} + +.dropdown-submenu:hover>.dropdown-menu { + display: block; +} + +.dropdown-submenu>a:after { + display: block; + content: " "; + float: right; + width: 0; + height: 0; + border-color: transparent; + border-style: solid; + border-width: 5px 0 5px 5px; + border-left-color: #cccccc; + margin-top: 5px; + margin-right: -10px; +} + +.dropdown-submenu:hover>a:after { + border-left-color: #ffffff; +} + +.dropdown-submenu.pull-left { + float: none; +} + +.dropdown-submenu.pull-left>.dropdown-menu { + left: -100%; + margin-left: 10px; + border-radius: 6px 0 6px 6px; +} + +/* Sidebar --------------------------*/ + +#pkgdown-sidebar { + margin-top: 30px; + position: -webkit-sticky; + position: sticky; + top: 70px; +} + +#pkgdown-sidebar h2 { + font-size: 1.5em; + margin-top: 1em; +} + +#pkgdown-sidebar h2:first-child { + margin-top: 0; +} + +#pkgdown-sidebar .list-unstyled li { + margin-bottom: 0.5em; +} + +/* bootstrap-toc tweaks ------------------------------------------------------*/ + +/* All levels of nav */ + +nav[data-toggle='toc'] .nav > li > a { + padding: 4px 20px 4px 6px; + font-size: 1.5rem; + font-weight: 400; + color: inherit; +} + +nav[data-toggle='toc'] .nav > li > a:hover, +nav[data-toggle='toc'] .nav > li > a:focus { + padding-left: 5px; + color: inherit; + border-left: 1px solid #878787; +} + +nav[data-toggle='toc'] .nav > .active > a, +nav[data-toggle='toc'] .nav > .active:hover > a, +nav[data-toggle='toc'] .nav > .active:focus > a { + padding-left: 5px; + font-size: 1.5rem; + font-weight: 400; + color: inherit; + border-left: 2px solid #878787; +} + +/* Nav: second level (shown on .active) */ + +nav[data-toggle='toc'] .nav .nav { + display: none; /* Hide by default, but at >768px, show it */ + padding-bottom: 10px; +} + +nav[data-toggle='toc'] .nav .nav > li > a { + padding-left: 16px; + font-size: 1.35rem; +} + +nav[data-toggle='toc'] .nav .nav > li > a:hover, +nav[data-toggle='toc'] .nav .nav > li > a:focus { + padding-left: 15px; +} + +nav[data-toggle='toc'] .nav .nav > .active > a, +nav[data-toggle='toc'] .nav .nav > .active:hover > a, +nav[data-toggle='toc'] .nav .nav > .active:focus > a { + padding-left: 15px; + font-weight: 500; + font-size: 1.35rem; +} + +/* orcid ------------------------------------------------------------------- */ + +.orcid { + font-size: 16px; + color: #A6CE39; + /* margins are required by official ORCID trademark and display guidelines */ + margin-left:4px; + margin-right:4px; + vertical-align: middle; +} + +/* Reference index & topics ----------------------------------------------- */ + +.ref-index th {font-weight: normal;} + +.ref-index td {vertical-align: top; min-width: 100px} +.ref-index .icon {width: 40px;} +.ref-index .alias {width: 40%;} +.ref-index-icons .alias {width: calc(40% - 40px);} +.ref-index .title {width: 60%;} + +.ref-arguments th {text-align: right; padding-right: 10px;} +.ref-arguments th, .ref-arguments td {vertical-align: top; min-width: 100px} +.ref-arguments .name {width: 20%;} +.ref-arguments .desc {width: 80%;} + +/* Nice scrolling for wide elements --------------------------------------- */ + +table { + display: block; + overflow: auto; +} + +/* Syntax highlighting ---------------------------------------------------- */ + +pre, code, pre code { + background-color: #f8f8f8; + color: #333; +} +pre, pre code { + white-space: pre-wrap; + word-break: break-all; + overflow-wrap: break-word; +} + +pre { + border: 1px solid #eee; +} + +pre .img, pre .r-plt { + margin: 5px 0; +} + +pre .img img, pre .r-plt img { + background-color: #fff; +} + +code a, pre a { + color: #375f84; +} + +a.sourceLine:hover { + text-decoration: none; +} + +.fl {color: #1514b5;} +.fu {color: #000000;} /* function */ +.ch,.st {color: #036a07;} /* string */ +.kw {color: #264D66;} /* keyword */ +.co {color: #888888;} /* comment */ + +.error {font-weight: bolder;} +.warning {font-weight: bolder;} + +/* Clipboard --------------------------*/ + +.hasCopyButton { + position: relative; +} + +.btn-copy-ex { + position: absolute; + right: 0; + top: 0; + visibility: hidden; +} + +.hasCopyButton:hover button.btn-copy-ex { + visibility: visible; +} + +/* headroom.js ------------------------ */ + +.headroom { + will-change: transform; + transition: transform 200ms linear; +} +.headroom--pinned { + transform: translateY(0%); +} +.headroom--unpinned { + transform: translateY(-100%); +} + +/* mark.js ----------------------------*/ + +mark { + background-color: rgba(255, 255, 51, 0.5); + border-bottom: 2px solid rgba(255, 153, 51, 0.3); + padding: 1px; +} + +/* vertical spacing after htmlwidgets */ +.html-widget { + margin-bottom: 10px; +} + +/* fontawesome ------------------------ */ + +.fab { + font-family: "Font Awesome 5 Brands" !important; +} + +/* don't display links in code chunks when printing */ +/* source: https://stackoverflow.com/a/10781533 */ +@media print { + code a:link:after, code a:visited:after { + content: ""; + } +} + +/* Section anchors --------------------------------- + Added in pandoc 2.11: https://github.com/jgm/pandoc-templates/commit/9904bf71 +*/ + +div.csl-bib-body { } +div.csl-entry { + clear: both; +} +.hanging-indent div.csl-entry { + margin-left:2em; + text-indent:-2em; +} +div.csl-left-margin { + min-width:2em; + float:left; +} +div.csl-right-inline { + margin-left:2em; + padding-left:1em; +} +div.csl-indent { + margin-left: 2em; +} diff --git a/pkgdown.js b/pkgdown.js new file mode 100644 index 000000000..6f0eee40b --- /dev/null +++ b/pkgdown.js @@ -0,0 +1,108 @@ +/* http://gregfranko.com/blog/jquery-best-practices/ */ +(function($) { + $(function() { + + $('.navbar-fixed-top').headroom(); + + $('body').css('padding-top', $('.navbar').height() + 10); + $(window).resize(function(){ + $('body').css('padding-top', $('.navbar').height() + 10); + }); + + $('[data-toggle="tooltip"]').tooltip(); + + var cur_path = paths(location.pathname); + var links = $("#navbar ul li a"); + var max_length = -1; + var pos = -1; + for (var i = 0; i < links.length; i++) { + if (links[i].getAttribute("href") === "#") + continue; + // Ignore external links + if (links[i].host !== location.host) + continue; + + var nav_path = paths(links[i].pathname); + + var length = prefix_length(nav_path, cur_path); + if (length > max_length) { + max_length = length; + pos = i; + } + } + + // Add class to parent
  • , and enclosing
  • if in dropdown + if (pos >= 0) { + var menu_anchor = $(links[pos]); + menu_anchor.parent().addClass("active"); + menu_anchor.closest("li.dropdown").addClass("active"); + } + }); + + function paths(pathname) { + var pieces = pathname.split("/"); + pieces.shift(); // always starts with / + + var end = pieces[pieces.length - 1]; + if (end === "index.html" || end === "") + pieces.pop(); + return(pieces); + } + + // Returns -1 if not found + function prefix_length(needle, haystack) { + if (needle.length > haystack.length) + return(-1); + + // Special case for length-0 haystack, since for loop won't run + if (haystack.length === 0) { + return(needle.length === 0 ? 0 : -1); + } + + for (var i = 0; i < haystack.length; i++) { + if (needle[i] != haystack[i]) + return(i); + } + + return(haystack.length); + } + + /* Clipboard --------------------------*/ + + function changeTooltipMessage(element, msg) { + var tooltipOriginalTitle=element.getAttribute('data-original-title'); + element.setAttribute('data-original-title', msg); + $(element).tooltip('show'); + element.setAttribute('data-original-title', tooltipOriginalTitle); + } + + if(ClipboardJS.isSupported()) { + $(document).ready(function() { + var copyButton = ""; + + $("div.sourceCode").addClass("hasCopyButton"); + + // Insert copy buttons: + $(copyButton).prependTo(".hasCopyButton"); + + // Initialize tooltips: + $('.btn-copy-ex').tooltip({container: 'body'}); + + // Initialize clipboard: + var clipboardBtnCopies = new ClipboardJS('[data-clipboard-copy]', { + text: function(trigger) { + return trigger.parentNode.textContent.replace(/\n#>[^\n]*/g, ""); + } + }); + + clipboardBtnCopies.on('success', function(e) { + changeTooltipMessage(e.trigger, 'Copied!'); + e.clearSelection(); + }); + + clipboardBtnCopies.on('error', function() { + changeTooltipMessage(e.trigger,'Press Ctrl+C or Command+C to copy'); + }); + }); + } +})(window.jQuery || window.$) diff --git a/pkgdown.yml b/pkgdown.yml new file mode 100644 index 000000000..5ad43d4c5 --- /dev/null +++ b/pkgdown.yml @@ -0,0 +1,5 @@ +pandoc: 3.9.0.2 +pkgdown: 2.2.0 +pkgdown_sha: ~ +articles: {} +last_built: 2026-06-02T01:04Z diff --git a/prereqs.html b/prereqs.html new file mode 100644 index 000000000..ff8052de1 --- /dev/null +++ b/prereqs.html @@ -0,0 +1,475 @@ + +Image Processing with Python: Prerequisites +
    +
    + + + + + +
    +
    +

    Prerequisites

    +

    Last updated on 2023-07-26 | + + Edit this page

    + + + +
    + +
    + + + +

    This lesson assumes you have a working knowledge of Python and some +previous exposure to the Bash shell.

    +

    These requirements can be fulfilled by:

    +
    1. completing a Software Carpentry Python workshop +or +
    2. +
    3. completing a Data Carpentry Ecology workshop (with Python) +and a Data Carpentry Genomics workshop +or +
    4. +
    5. coursework in or independent learning of both Python and the Bash +shell.
    6. +
    +

    Bash shell skills

    +

    The skill set listed below is covered in any Software Carpentry +workshop, as well as in Data Carpentry’s Genomics workshop. These skills +can also be learned through coursework or independent learning.

    +

    Be able to:

    +
    • Identify and navigate to your home directory.
    • +
    • Identify your current working directory.
    • +
    • Navigating directories using pwd, ls, +cd <subdirectory>, and cd .. +
    • +
    • Run a Python script from the command line.
    • +
    +
    +

    Python skills

    +

    This skill set listed below is covered in both Software Carpentry’s +Python workshop and in Data Carpentry’s Ecology workshop with Python. +These skills can also be learned through coursework or independent +learning.

    +

    Be able to:

    +
    • Use the assignment operator to create int, +float, and str variables.
    • +
    • Perform basic arithmetic operations (e.g. addition, subtraction) on +variables.
    • +
    • Convert strings to ints or floats where appropriate.
    • +
    • Create a list and alter lists by appending, inserting, +or removing values.
    • +
    • Use indexing and slicing to access elements of strings, lists, and +NumPy arrays.
    • +
    • Use good coding practices to comment your code and choose +appropriate variable names.
    • +
    • Write a for loop that increments a variable.
    • +
    • Write conditional statements using if, +elif, and else.
    • +
    • Use comparison operators (==, !=, +<, <=, >, +>=) in conditional statements.
    • +
    • Read data from a file using read(), +readline(), and readlines().
    • +
    • Open, read from, write to, and close input and output files.
    • +
    • Use print() and len() to inspect +variables.
    • +

    The following skills are useful, but not required:

    +
    • Apply a function to an entire NumPy array or to a single array +axis.
    • +
    • Write a user-defined function.
    • +

    If you are signed up, or considering signing up for a workshop, and +aren’t sure whether you meet these reqirements, please get in touch with +the workshop instructors or host.

    +
    + + + +
    +
    + + +
    +
    + + + diff --git a/profiles.html b/profiles.html new file mode 100644 index 000000000..e29a8c0ee --- /dev/null +++ b/profiles.html @@ -0,0 +1,381 @@ + +Image Processing with Python: Learner Profiles +
    +
    + + + + + +
    +
    +

    Learner Profiles

    + +

    This is a placeholder file. Please add content here.

    + +
    +
    + + +
    +
    + + + diff --git a/profiles/learner-profiles.md b/profiles/learner-profiles.md deleted file mode 100644 index 434e335aa..000000000 --- a/profiles/learner-profiles.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: FIXME ---- - -This is a placeholder file. Please add content here. diff --git a/reference.html b/reference.html new file mode 100644 index 000000000..cbe1be2dd --- /dev/null +++ b/reference.html @@ -0,0 +1,587 @@ + +Image Processing with Python: Glossary +
    +
    + + + + + +
    +
    +

    Glossary

    +

    Last updated on 2026-03-23 | + + Edit this page

    + + + +
    + +
    + + +

    Glossary

    +

    (Some definitions are taken from Glosario. Follow the links +from terms to see definitions in languages other than English.)

    +

    {:auto_ids}

    +
    adaptive thresholding
    +
    +thresholding that uses a cut-off value that varies for pixels in +different regions of the image. +
    +
    additive colour model
    +
    +a colour model that predicts the appearance of colours by summing the +numeric representations of the component colours. +
    +
    bacterial colony
    +
    +a visible cluster of bacteria growing on the surface of or within a +solid medium, presumably cultured from a single cell. +
    +
    binary image
    +
    +an image of pixels with only two possible values, 0 and 1. Typically, +the two colours used for a binary image are black and white. +
    +
    bit
    +
    +a unit of information representing alternatives, yes/no, true/false. In +computing a state of either 0 or 1. +
    +
    blur
    +
    +the averaging of pixel intensities within a neighbourhood. This has the +effect of “softening” the features of the image, reducing noise and +finer detail. +
    +
    BMP (bitmap image file)
    +
    +a raster graphics image file format used to store bitmap digital images, +independently of the display device. +
    +
    bounding box
    +
    +the smallest enclosing box for a set of points. +
    +
    byte
    +
    +a unit of digital information that typically consists of eight binary +digits, or bits. +
    +
    colorimetrics
    +
    +the processing and analysis of objects based on their colour. +
    +
    compression
    +
    +a class of data encoding methods that aims to reduce the size of a file +while retaining some or all of the information it contains. +
    +
    channel
    +
    +a set of pixel intensities within an image that were measured in the +same way e.g. at a given wavelength. +
    +
    crop
    +
    +the removal of unwanted outer areas from an image. +
    +
    colour histogram
    +
    +a representation of the number of pixels that have colours in each of a +fixed list of colour ranges. +
    +
    edge detection
    +
    +a variety of methods that attempt to automatically identify the +boundaries of objects within an image. +
    +
    fixed-level thresholding
    +
    +thresholding that uses a single, constant cut-off value for every pixel +in the image. +
    +
    grayscale
    +
    +an image in which the value of each pixel is a single value representing +only the amount of light (or intensity) of that pixel. +
    +
    histogram
    +
    +a graphical representation of the distribution of a set of numeric data, +usually a vertical bar graph. +
    +
    image segmentation
    +
    +the process of dividing an image into multiple sections, to be processed +or analysed independently. +
    +
    intensity
    +
    +the value measured at a given pixel in the image. +
    +
    JPEG
    +
    +a commonly used method of lossy compression for digital images, +particularly for those images produced by digital photography +
    +
    kernel
    +
    +a matrix, usually relatively small, defining a neighbourhood of pixel +intensities that will be considered during blurring, edge detection, and +other operations. +
    +
    left-hand coordinate system
    +
    +a system of coordinates where the origin is at the top-left extreme of +the image, and coordinates increase as you move down the y axis. +
    +
    lossy compression
    +
    +a class of data compression methods that uses inexact approximations and +partial data discarding to represent the content. +
    +
    lossless compression
    +
    +a class of data compression methods that allows the original data to be +perfectly reconstructed from the compressed data. +
    +
    maize
    +
    +a common crop plant grown in many regions of the world. Also known as +corn. +
    +
    mask
    +
    +a binary matrix, usually of the same dimensions as the target image, +representing which pixels should be included and excluded in further +processing and analysis. +
    +
    morphometrics
    +
    +the processing and analysis of objects based on their size and shape. +
    +
    noise
    +
    +random variation of brightness or colour information in images. An +undesirable by-product of image capture that obscures the desired +information. +
    +
    pixel
    +
    +the individual units of intensity that make up an image. +
    +
    raster +graphics
    +
    +images stored as a matrix of pixels. +
    +
    RGB colour model
    +
    +an additive colour model describing colour in a image with a combination +of pixel intensities in three channels: red, green, and blue. +
    +
    thresholding
    +
    +the process of creating a binary version of a grayscale image, based on +whether pixel values fall above or below a given limit or cut-off value. +
    +
    TIFF (Tagged Image File Format)
    +
    +a computer file format for storing raster graphics images; also +abbreviated TIF +
    +
    titration
    +
    +a common laboratory method of quantitative chemical analysis to +determine the concentration of an identified analyte (a substance to be +analyzed) +
    +
    +
    + + +
    +
    + + + diff --git a/safari-pinned-tab.svg b/safari-pinned-tab.svg new file mode 100644 index 000000000..8a74e60c8 --- /dev/null +++ b/safari-pinned-tab.svg @@ -0,0 +1,68 @@ + + + + +Created by potrace 1.14, written by Peter Selinger 2001-2017 + + + + + + + + diff --git a/site.webmanifest b/site.webmanifest new file mode 100644 index 000000000..f2302ffdd --- /dev/null +++ b/site.webmanifest @@ -0,0 +1,19 @@ +{ + "name": "The Carpentries", + "short_name": "The Carpentries", + "icons": [ + { + "src": "/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} diff --git a/site/README.md b/site/README.md deleted file mode 100644 index 42997e3d0..000000000 --- a/site/README.md +++ /dev/null @@ -1,2 +0,0 @@ -This directory contains rendered lesson materials. Please do not edit files -here. diff --git a/sitemap.xml b/sitemap.xml new file mode 100644 index 000000000..1cfc93cf4 --- /dev/null +++ b/sitemap.xml @@ -0,0 +1,144 @@ + + + + https://datacarpentry.github.io/image-processing/01-introduction.html + + + https://datacarpentry.github.io/image-processing/02-image-basics.html + + + https://datacarpentry.github.io/image-processing/03-skimage-images.html + + + https://datacarpentry.github.io/image-processing/04-drawing.html + + + https://datacarpentry.github.io/image-processing/05-creating-histograms.html + + + https://datacarpentry.github.io/image-processing/06-blurring.html + + + https://datacarpentry.github.io/image-processing/07-thresholding.html + + + https://datacarpentry.github.io/image-processing/08-connected-components.html + + + https://datacarpentry.github.io/image-processing/09-challenges.html + + + https://datacarpentry.github.io/image-processing/404.html + + + https://datacarpentry.github.io/image-processing/CODE_OF_CONDUCT.html + + + https://datacarpentry.github.io/image-processing/LICENSE.html + + + https://datacarpentry.github.io/image-processing/aio.html + + + https://datacarpentry.github.io/image-processing/discuss.html + + + https://datacarpentry.github.io/image-processing/edge-detection.html + + + https://datacarpentry.github.io/image-processing/files/cheatsheet.html + + + https://datacarpentry.github.io/image-processing/further-reading.html + + + https://datacarpentry.github.io/image-processing/images.html + + + https://datacarpentry.github.io/image-processing/index.html + + + https://datacarpentry.github.io/image-processing/instructor/01-introduction.html + + + https://datacarpentry.github.io/image-processing/instructor/02-image-basics.html + + + https://datacarpentry.github.io/image-processing/instructor/03-skimage-images.html + + + https://datacarpentry.github.io/image-processing/instructor/04-drawing.html + + + https://datacarpentry.github.io/image-processing/instructor/05-creating-histograms.html + + + https://datacarpentry.github.io/image-processing/instructor/06-blurring.html + + + https://datacarpentry.github.io/image-processing/instructor/07-thresholding.html + + + https://datacarpentry.github.io/image-processing/instructor/08-connected-components.html + + + https://datacarpentry.github.io/image-processing/instructor/09-challenges.html + + + https://datacarpentry.github.io/image-processing/instructor/404.html + + + https://datacarpentry.github.io/image-processing/instructor/CODE_OF_CONDUCT.html + + + https://datacarpentry.github.io/image-processing/instructor/LICENSE.html + + + https://datacarpentry.github.io/image-processing/instructor/aio.html + + + https://datacarpentry.github.io/image-processing/instructor/discuss.html + + + https://datacarpentry.github.io/image-processing/instructor/edge-detection.html + + + https://datacarpentry.github.io/image-processing/instructor/further-reading.html + + + https://datacarpentry.github.io/image-processing/instructor/images.html + + + https://datacarpentry.github.io/image-processing/instructor/index.html + + + https://datacarpentry.github.io/image-processing/instructor/instructor-notes.html + + + https://datacarpentry.github.io/image-processing/instructor/key-points.html + + + https://datacarpentry.github.io/image-processing/instructor/prereqs.html + + + https://datacarpentry.github.io/image-processing/instructor/profiles.html + + + https://datacarpentry.github.io/image-processing/instructor/reference.html + + + https://datacarpentry.github.io/image-processing/instructor-notes.html + + + https://datacarpentry.github.io/image-processing/key-points.html + + + https://datacarpentry.github.io/image-processing/prereqs.html + + + https://datacarpentry.github.io/image-processing/profiles.html + + + https://datacarpentry.github.io/image-processing/reference.html + +